1 #!/usr/bin/env python 2 3 """ 4 A Web interface to a user's calendar. 5 6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 # Edit this path to refer to the location of the imiptools libraries, if 23 # necessary. 24 25 LIBRARY_PATH = "/var/lib/imip-agent" 26 27 from datetime import date, datetime, timedelta 28 import babel.dates 29 import cgi, os, sys 30 31 sys.path.append(LIBRARY_PATH) 32 33 from imiptools.content import Handler 34 from imiptools.data import get_address, get_uri, make_freebusy, parse_object, \ 35 Object, to_part 36 from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \ 37 get_datetime_item, get_default_timezone, \ 38 get_end_of_day, get_start_of_day, get_start_of_next_day, \ 39 get_timestamp, ends_on_same_day, to_timezone 40 from imiptools.mail import Messenger 41 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ 42 convert_periods, get_freebusy_details, \ 43 get_scale, have_conflict, get_slots, get_spans, \ 44 partition_by_day, remove_from_freebusy, update_freebusy 45 from imiptools.profile import Preferences 46 import imip_store 47 import markup 48 49 getenv = os.environ.get 50 setenv = os.environ.__setitem__ 51 52 class CGIEnvironment: 53 54 "A CGI-compatible environment." 55 56 def __init__(self, charset=None): 57 self.charset = charset 58 self.args = None 59 self.method = None 60 self.path = None 61 self.path_info = None 62 self.user = None 63 64 def get_args(self): 65 if self.args is None: 66 if self.get_method() != "POST": 67 setenv("QUERY_STRING", "") 68 args = cgi.parse(keep_blank_values=True) 69 70 if not self.charset: 71 self.args = args 72 else: 73 self.args = {} 74 for key, values in args.items(): 75 self.args[key] = [unicode(value, self.charset) for value in values] 76 77 return self.args 78 79 def get_method(self): 80 if self.method is None: 81 self.method = getenv("REQUEST_METHOD") or "GET" 82 return self.method 83 84 def get_path(self): 85 if self.path is None: 86 self.path = getenv("SCRIPT_NAME") or "" 87 return self.path 88 89 def get_path_info(self): 90 if self.path_info is None: 91 self.path_info = getenv("PATH_INFO") or "" 92 return self.path_info 93 94 def get_user(self): 95 if self.user is None: 96 self.user = getenv("REMOTE_USER") or "" 97 return self.user 98 99 def get_output(self): 100 return sys.stdout 101 102 def get_url(self): 103 path = self.get_path() 104 path_info = self.get_path_info() 105 return "%s%s" % (path.rstrip("/"), path_info) 106 107 def new_url(self, path_info): 108 path = self.get_path() 109 return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/")) 110 111 class ManagerHandler(Handler): 112 113 """ 114 A content handler for use by the manager, as opposed to operating within the 115 mail processing pipeline. 116 """ 117 118 def __init__(self, obj, user, messenger): 119 Handler.__init__(self, messenger=messenger) 120 self.set_object(obj) 121 self.user = user 122 123 self.organiser = self.obj.get_value("ORGANIZER") 124 self.attendees = self.obj.get_values("ATTENDEE") 125 126 # Communication methods. 127 128 def send_message(self, method, sender, for_organiser): 129 130 """ 131 Create a full calendar object employing the given 'method', and send it 132 to the appropriate recipients, also sending a copy to the 'sender'. The 133 'for_organiser' value indicates whether the organiser is sending this 134 message. 135 """ 136 137 parts = [self.obj.to_part(method)] 138 139 # As organiser, send an invitation to attendees, excluding oneself if 140 # also attending. The updated event will be saved by the outgoing 141 # handler. 142 143 if for_organiser: 144 recipients = [get_address(attendee) for attendee in self.attendees if attendee != self.user] 145 else: 146 recipients = [get_address(self.organiser)] 147 148 # Bundle free/busy information if appropriate. 149 150 preferences = Preferences(self.user) 151 152 if preferences.get("freebusy_sharing") == "share" and \ 153 preferences.get("freebusy_bundling") == "always": 154 155 # Invent a unique identifier. 156 157 utcnow = get_timestamp() 158 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 159 160 freebusy = self.store.get_freebusy(self.user) 161 user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \ 162 {"SENT-BY" : get_uri(self.messenger.sender)} or {} 163 164 parts.append(to_part("PUBLISH", [ 165 make_freebusy(freebusy, uid, self.user, user_attr) 166 ])) 167 168 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 169 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 170 171 # Action methods. 172 173 def process_received_request(self, update=False): 174 175 """ 176 Process the current request for the given 'user'. Return whether any 177 action was taken. 178 179 If 'update' is given, the sequence number will be incremented in order 180 to override any previous response. 181 """ 182 183 # Reply only on behalf of this user. 184 185 for attendee, attendee_attr in self.obj.get_items("ATTENDEE"): 186 187 if attendee == self.user: 188 if attendee_attr.has_key("RSVP"): 189 del attendee_attr["RSVP"] 190 if self.messenger and self.messenger.sender != get_address(attendee): 191 attendee_attr["SENT-BY"] = get_uri(self.messenger.sender) 192 self.obj["ATTENDEE"] = [(attendee, attendee_attr)] 193 194 self.update_dtstamp() 195 self.set_sequence(update) 196 197 self.send_message("REPLY", get_address(attendee), for_organiser=False) 198 199 return True 200 201 return False 202 203 def process_created_request(self, method, update=False): 204 205 """ 206 Process the current request for the given 'user', sending a created 207 request of the given 'method' to attendees. Return whether any action 208 was taken. 209 210 If 'update' is given, the sequence number will be incremented in order 211 to override any previous message. 212 """ 213 214 organiser, organiser_attr = self.obj.get_item("ORGANIZER") 215 216 if self.messenger and self.messenger.sender != get_address(organiser): 217 organiser_attr["SENT-BY"] = get_uri(self.messenger.sender) 218 219 self.update_dtstamp() 220 self.set_sequence(update) 221 222 self.send_message(method, get_address(self.organiser), for_organiser=True) 223 return True 224 225 class Manager: 226 227 "A simple manager application." 228 229 def __init__(self, messenger=None): 230 self.messenger = messenger or Messenger() 231 232 self.encoding = "utf-8" 233 self.env = CGIEnvironment(self.encoding) 234 235 user = self.env.get_user() 236 self.user = user and get_uri(user) or None 237 self.preferences = None 238 self.locale = None 239 self.requests = None 240 241 self.out = self.env.get_output() 242 self.page = markup.page() 243 244 self.store = imip_store.FileStore() 245 self.objects = {} 246 247 try: 248 self.publisher = imip_store.FilePublisher() 249 except OSError: 250 self.publisher = None 251 252 def _get_uid(self, path_info): 253 return path_info.lstrip("/").split("/", 1)[0] 254 255 def _get_object(self, uid): 256 if self.objects.has_key(uid): 257 return self.objects[uid] 258 259 f = uid and self.store.get_event(self.user, uid) or None 260 261 if not f: 262 return None 263 264 fragment = parse_object(f, "utf-8") 265 obj = self.objects[uid] = fragment and Object(fragment) 266 267 return obj 268 269 def _get_requests(self): 270 if self.requests is None: 271 self.requests = self.store.get_requests(self.user) 272 return self.requests 273 274 def _get_request_summary(self): 275 summary = [] 276 for uid in self._get_requests(): 277 obj = self._get_object(uid) 278 if obj: 279 summary.append(( 280 obj.get_value("DTSTART"), 281 obj.get_value("DTEND"), 282 uid 283 )) 284 return summary 285 286 # Preference methods. 287 288 def get_user_locale(self): 289 if not self.locale: 290 self.locale = self.get_preferences().get("LANG", "C") 291 return self.locale 292 293 def get_preferences(self): 294 if not self.preferences: 295 self.preferences = Preferences(self.user) 296 return self.preferences 297 298 def get_tzid(self): 299 prefs = self.get_preferences() 300 return prefs.get("TZID") or get_default_timezone() 301 302 # Prettyprinting of dates and times. 303 304 def format_date(self, dt, format): 305 return self._format_datetime(babel.dates.format_date, dt, format) 306 307 def format_time(self, dt, format): 308 return self._format_datetime(babel.dates.format_time, dt, format) 309 310 def format_datetime(self, dt, format): 311 return self._format_datetime( 312 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 313 dt, format) 314 315 def _format_datetime(self, fn, dt, format): 316 return fn(dt, format=format, locale=self.get_user_locale()) 317 318 # Data management methods. 319 320 def remove_request(self, uid): 321 return self.store.dequeue_request(self.user, uid) 322 323 def remove_event(self, uid): 324 return self.store.remove_event(self.user, uid) 325 326 def update_freebusy(self, uid, obj): 327 tzid = self.get_tzid() 328 freebusy = self.store.get_freebusy(self.user) 329 update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid), 330 obj.get_value("TRANSP"), uid, self.store) 331 332 def remove_from_freebusy(self, uid): 333 freebusy = self.store.get_freebusy(self.user) 334 remove_from_freebusy(freebusy, self.user, uid, self.store) 335 336 # Presentation methods. 337 338 def new_page(self, title): 339 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 340 341 def status(self, code, message): 342 self.header("Status", "%s %s" % (code, message)) 343 344 def header(self, header, value): 345 print >>self.out, "%s: %s" % (header, value) 346 347 def no_user(self): 348 self.status(403, "Forbidden") 349 self.new_page(title="Forbidden") 350 self.page.p("You are not logged in and thus cannot access scheduling requests.") 351 352 def no_page(self): 353 self.status(404, "Not Found") 354 self.new_page(title="Not Found") 355 self.page.p("No page is provided at the given address.") 356 357 def redirect(self, url): 358 self.status(302, "Redirect") 359 self.header("Location", url) 360 self.new_page(title="Redirect") 361 self.page.p("Redirecting to: %s" % url) 362 363 # Request logic methods. 364 365 def handle_newevent(self): 366 367 """ 368 Handle any new event operation, creating a new event and redirecting to 369 the event page for further activity. 370 """ 371 372 # Handle a submitted form. 373 374 args = self.env.get_args() 375 376 if not args.has_key("newevent"): 377 return 378 379 # Create a new event using the available information. 380 381 slots = args.get("slot", []) 382 participants = args.get("participants", []) 383 384 if not slots: 385 return 386 387 # Obtain the user's timezone. 388 389 tzid = self.get_tzid() 390 391 # Coalesce the selected slots. 392 393 slots.sort() 394 coalesced = [] 395 last = None 396 397 for slot in slots: 398 start, end = slot.split("-") 399 start = get_datetime(start, {"TZID" : tzid}) 400 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 401 402 if last: 403 last_start, last_end = last 404 405 # Merge adjacent dates and datetimes. 406 407 if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 408 last = last_start, end 409 continue 410 411 # Handle datetimes within dates. 412 # Datetime periods are within single days and are therefore 413 # discarded. 414 415 elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 416 continue 417 418 # Add separate dates and datetimes. 419 420 else: 421 coalesced.append(last) 422 423 last = start, end 424 425 if last: 426 coalesced.append(last) 427 428 # Invent a unique identifier. 429 430 utcnow = get_timestamp() 431 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 432 433 # Define a single occurrence if only one coalesced slot exists. 434 # Otherwise, many occurrences are defined. 435 436 for i, (start, end) in enumerate(coalesced): 437 this_uid = "%s-%s" % (uid, i) 438 439 start_value, start_attr = get_datetime_item(start, tzid) 440 end_value, end_attr = get_datetime_item(end, tzid) 441 442 # Create a calendar object and store it as a request. 443 444 record = [] 445 rwrite = record.append 446 447 rwrite(("UID", {}, this_uid)) 448 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 449 rwrite(("DTSTAMP", {}, utcnow)) 450 rwrite(("DTSTART", start_attr, start_value)) 451 rwrite(("DTEND", end_attr, end_value)) 452 rwrite(("ORGANIZER", {}, self.user)) 453 454 for participant in participants: 455 if not participant: 456 continue 457 participant = get_uri(participant) 458 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) 459 460 obj = ("VEVENT", {}, record) 461 462 self.store.set_event(self.user, this_uid, obj) 463 self.store.queue_request(self.user, this_uid) 464 465 # Redirect to the object (or the first of the objects), where instead of 466 # attendee controls, there will be organiser controls. 467 468 self.redirect(self.env.new_url("%s-0" % uid)) 469 470 def handle_request(self, uid, obj): 471 472 "Handle actions involving the given 'uid' and 'obj' object." 473 474 # Handle a submitted form. 475 476 args = self.env.get_args() 477 handled = True 478 479 # Update the object. 480 481 if args.has_key("summary"): 482 obj["SUMMARY"] = [(args["summary"][0], {})] 483 484 if args.has_key("partstat"): 485 organisers = obj.get_value_map("ORGANIZER") 486 attendees = obj.get_value_map("ATTENDEE") 487 for d in attendees, organisers: 488 if d.has_key(self.user): 489 d[self.user]["PARTSTAT"] = args["partstat"][0] 490 if d[self.user].has_key("RSVP"): 491 del d[self.user]["RSVP"] 492 493 is_organiser = obj.get_value("ORGANIZER") == self.user 494 495 # Obtain the user's timezone and process datetime values. 496 497 update = False 498 499 if is_organiser: 500 t = self.handle_date_controls("dtstart") 501 if t: 502 dtstart, attr = t 503 update = self.set_datetime_in_object(dtstart, attr["TZID"], "DTSTART", obj) or update 504 else: 505 return False 506 507 # Handle specified end datetimes. 508 509 if args.get("dtend-control", [None])[0] == "enable": 510 t = self.handle_date_controls("dtend") 511 if t: 512 dtend, attr = t 513 514 # Convert end dates to iCalendar "next day" dates. 515 516 if not isinstance(dtend, datetime): 517 dtend += timedelta(1) 518 update = self.set_datetime_in_object(dtend, attr["TZID"], "DTEND", obj) or update 519 else: 520 return False 521 522 # Otherwise, treat the end date as the start date. Datetimes cannot 523 # be duplicated in such a way. 524 525 else: 526 if isinstance(dtstart, datetime): 527 return False 528 dtend = dtstart + timedelta(1) 529 update = self.set_datetime_in_object(dtend, attr["TZID"], "DTEND", obj) or update 530 531 if dtstart >= dtend: 532 return False 533 534 # Process any action. 535 536 reply = args.has_key("reply") 537 discard = args.has_key("discard") 538 invite = args.has_key("invite") 539 cancel = args.has_key("cancel") 540 save = args.has_key("save") 541 542 if reply or invite or cancel: 543 544 handler = ManagerHandler(obj, self.user, self.messenger) 545 546 # Process the object and remove it from the list of requests. 547 548 if reply and handler.process_received_request(update) or \ 549 (invite or cancel) and handler.process_created_request(invite and "REQUEST" or "CANCEL", update): 550 551 self.remove_request(uid) 552 553 # Save single user events. 554 555 elif save: 556 self.store.set_event(self.user, uid, obj.to_node()) 557 self.update_freebusy(uid, obj) 558 self.remove_request(uid) 559 560 # Remove the request and the object. 561 562 elif discard: 563 self.remove_from_freebusy(uid) 564 self.remove_event(uid) 565 self.remove_request(uid) 566 567 else: 568 handled = False 569 570 # Upon handling an action, redirect to the main page. 571 572 if handled: 573 self.redirect(self.env.get_path()) 574 575 return handled 576 577 def handle_date_controls(self, name): 578 579 """ 580 Handle date control information for fields starting with 'name', 581 returning a (datetime, attr) tuple or None if the fields cannot be used 582 to construct a datetime object. 583 """ 584 585 args = self.env.get_args() 586 tzid = self.get_tzid() 587 588 if args.has_key("%s-date" % name): 589 date = args["%s-date" % name][0] 590 hour = args.get("%s-hour" % name, [None])[0] 591 minute = args.get("%s-minute" % name, [None])[0] 592 second = args.get("%s-second" % name, [None])[0] 593 tzid = args.get("%s-tzid" % name, [tzid])[0] 594 595 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or "" 596 value = "%s%s" % (date, time) 597 attr = {"TZID" : tzid} 598 dt = get_datetime(value, attr) 599 if dt: 600 return dt, attr 601 602 return None 603 604 def set_datetime_in_object(self, dt, tzid, property, obj): 605 606 """ 607 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 608 an update has occurred. 609 """ 610 611 if dt: 612 old_value = obj.get_value(property) 613 obj[property] = [get_datetime_item(dt, tzid)] 614 return format_datetime(dt) != old_value 615 616 return False 617 618 # Page fragment methods. 619 620 def show_request_controls(self, obj): 621 622 "Show form controls for a request concerning 'obj'." 623 624 page = self.page 625 626 is_organiser = obj.get_value("ORGANIZER") == self.user 627 628 attendees = obj.get_value_map("ATTENDEE") 629 is_attendee = attendees.has_key(self.user) 630 attendee_attr = attendees.get(self.user) 631 632 is_request = obj.get_value("UID") in self._get_requests() 633 634 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 635 636 # Show appropriate options depending on the role of the user. 637 638 if is_attendee and not is_organiser: 639 page.p("An action is required for this request:") 640 641 page.p() 642 page.input(name="reply", type="submit", value="Reply") 643 page.add(" ") 644 page.input(name="discard", type="submit", value="Discard") 645 page.p.close() 646 647 if is_organiser: 648 if have_other_attendees: 649 page.p("As organiser, you can perform the following:") 650 651 page.p() 652 page.input(name="invite", type="submit", value="Invite") 653 page.add(" ") 654 if is_request: 655 page.input(name="discard", type="submit", value="Discard") 656 else: 657 page.input(name="cancel", type="submit", value="Cancel") 658 page.p.close() 659 else: 660 page.p() 661 page.input(name="save", type="submit", value="Save") 662 page.add(" ") 663 page.input(name="discard", type="submit", value="Discard") 664 page.p.close() 665 666 property_items = [ 667 ("SUMMARY", "Summary"), 668 ("DTSTART", "Start"), 669 ("DTEND", "End"), 670 ("ORGANIZER", "Organiser"), 671 ("ATTENDEE", "Attendee"), 672 ] 673 674 partstat_items = [ 675 ("NEEDS-ACTION", "Not confirmed"), 676 ("ACCEPTED", "Attending"), 677 ("TENTATIVE", "Tentatively attending"), 678 ("DECLINED", "Not attending"), 679 ("DELEGATED", "Delegated"), 680 ] 681 682 def show_object_on_page(self, uid, obj): 683 684 """ 685 Show the calendar object with the given 'uid' and representation 'obj' 686 on the current page. 687 """ 688 689 page = self.page 690 page.form(method="POST") 691 692 # Obtain the user's timezone. 693 694 tzid = self.get_tzid() 695 696 # Provide controls to change the displayed object. 697 698 args = self.env.get_args() 699 700 t = self.handle_date_controls("dtstart") 701 if t: 702 dtstart, dtstart_attr = t 703 else: 704 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 705 706 dtend_control = args.get("dtend-control", [None])[0] 707 708 if dtend_control == "enable": 709 t = self.handle_date_controls("dtend") 710 if t: 711 dtend, dtend_attr = t 712 else: 713 dtend, dtend_attr = None, {} 714 elif dtend_control == "disable": 715 dtend, dtend_attr = None, {} 716 else: 717 dtend, dtend_attr = obj.get_datetime_item("DTEND") 718 719 # Change end dates to refer to the actual dates, not the iCalendar 720 # "next day" dates. 721 722 if dtend and not isinstance(dtend, datetime): 723 dtend -= timedelta(1) 724 725 # Show the end datetime controls if already active or if an object needs 726 # them. 727 728 dtend_control = dtend_control or (isinstance(dtend, datetime) or dtstart != dtend) and "enable" or None 729 730 if dtend_control == "enable": 731 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 732 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 733 else: 734 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 735 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 736 737 # Provide a summary of the object. 738 739 page.table(class_="object", cellspacing=5, cellpadding=5) 740 page.thead() 741 page.tr() 742 page.th("Event", class_="mainheading", colspan=2) 743 page.tr.close() 744 page.thead.close() 745 page.tbody() 746 747 is_organiser = obj.get_value("ORGANIZER") == self.user 748 749 for name, label in self.property_items: 750 page.tr() 751 752 # Handle datetimes specially. 753 754 if name in ["DTSTART", "DTEND"]: 755 756 page.th(label, class_="objectheading %s" % name.lower()) 757 758 # Obtain the datetime. 759 760 if name == "DTSTART": 761 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 762 763 # Where no end datetime exists, use the start datetime as the 764 # basis of any potential datetime specified if dt-control is 765 # set. 766 767 else: 768 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 769 770 strvalue = self.format_datetime(dt, "full") 771 value = format_datetime(dt) 772 773 if is_organiser: 774 page.td(class_="objectvalue %s" % name.lower()) 775 if name == "DTEND": 776 page.div(class_="disabled") 777 page.label("Specify end date", for_="dtend-enable", class_="enable") 778 page.div.close() 779 780 page.div(class_="enabled") 781 self._show_date_controls(name.lower(), value, attr, tzid) 782 if name == "DTEND": 783 page.label("End on same day", for_="dtend-disable", class_="disable") 784 page.div.close() 785 786 page.td.close() 787 else: 788 page.td(strvalue) 789 790 page.tr.close() 791 792 # Handle the summary specially. 793 794 elif name == "SUMMARY": 795 value = args.get("summary", [obj.get_value(name)])[0] 796 797 page.th(label, class_="objectheading") 798 page.td() 799 if is_organiser: 800 page.input(name="summary", type="text", value=value, size=80) 801 else: 802 page.add(value) 803 page.td.close() 804 page.tr.close() 805 806 # Handle potentially many values. 807 808 else: 809 items = obj.get_items(name) 810 if not items: 811 continue 812 813 page.th(label, class_="objectheading", rowspan=len(items)) 814 815 first = True 816 817 for value, attr in items: 818 if not first: 819 page.tr() 820 else: 821 first = False 822 823 if name in ("ATTENDEE", "ORGANIZER"): 824 page.td(class_="objectattribute") 825 page.add(value) 826 page.add(" ") 827 828 partstat = attr.get("PARTSTAT") 829 if value == self.user and (not is_organiser or name == "ORGANIZER"): 830 self._show_menu("partstat", partstat, self.partstat_items) 831 else: 832 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 833 else: 834 page.td(class_="objectattribute") 835 page.add(value) 836 837 page.td.close() 838 page.tr.close() 839 840 page.tbody.close() 841 page.table.close() 842 843 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 844 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 845 846 # Indicate whether there are conflicting events. 847 848 freebusy = self.store.get_freebusy(self.user) 849 850 if freebusy: 851 852 # Obtain any time zone details from the suggested event. 853 854 _dtstart, attr = obj.get_item("DTSTART") 855 tzid = attr.get("TZID", tzid) 856 857 # Show any conflicts. 858 859 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 860 start, end, found_uid = t[:3] 861 862 # Provide details of any conflicting event. 863 864 if uid != found_uid: 865 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 866 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 867 page.p("Event conflicts with another from %s to %s: " % (start, end)) 868 869 # Show the event summary for the conflicting event. 870 871 found_obj = self._get_object(found_uid) 872 if found_obj: 873 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 874 875 self.show_request_controls(obj) 876 page.form.close() 877 878 def show_requests_on_page(self): 879 880 "Show requests for the current user." 881 882 # NOTE: This list could be more informative, but it is envisaged that 883 # NOTE: the requests would be visited directly anyway. 884 885 requests = self._get_requests() 886 887 self.page.div(id="pending-requests") 888 889 if requests: 890 self.page.p("Pending requests:") 891 892 self.page.ul() 893 894 for request in requests: 895 obj = self._get_object(request) 896 if obj: 897 self.page.li() 898 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 899 self.page.li.close() 900 901 self.page.ul.close() 902 903 else: 904 self.page.p("There are no pending requests.") 905 906 self.page.div.close() 907 908 def show_participants_on_page(self): 909 910 "Show participants for scheduling purposes." 911 912 args = self.env.get_args() 913 participants = args.get("participants", []) 914 915 try: 916 for name, value in args.items(): 917 if name.startswith("remove-participant-"): 918 i = int(name[len("remove-participant-"):]) 919 del participants[i] 920 break 921 except ValueError: 922 pass 923 924 # Trim empty participants. 925 926 while participants and not participants[-1].strip(): 927 participants.pop() 928 929 # Show any specified participants together with controls to remove and 930 # add participants. 931 932 self.page.div(id="participants") 933 934 self.page.p("Participants for scheduling:") 935 936 for i, participant in enumerate(participants): 937 self.page.p() 938 self.page.input(name="participants", type="text", value=participant) 939 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 940 self.page.p.close() 941 942 self.page.p() 943 self.page.input(name="participants", type="text") 944 self.page.input(name="add-participant", type="submit", value="Add") 945 self.page.p.close() 946 947 self.page.div.close() 948 949 return participants 950 951 # Full page output methods. 952 953 def show_object(self, path_info): 954 955 "Show an object request using the given 'path_info' for the current user." 956 957 uid = self._get_uid(path_info) 958 obj = self._get_object(uid) 959 960 if not obj: 961 return False 962 963 handled = self.handle_request(uid, obj) 964 965 if handled: 966 return True 967 968 self.new_page(title="Event") 969 self.show_object_on_page(uid, obj) 970 971 return True 972 973 def show_calendar(self): 974 975 "Show the calendar for the current user." 976 977 handled = self.handle_newevent() 978 979 self.new_page(title="Calendar") 980 page = self.page 981 982 # Form controls are used in various places on the calendar page. 983 984 page.form(method="POST") 985 986 self.show_requests_on_page() 987 participants = self.show_participants_on_page() 988 989 # Show a button for scheduling a new event. 990 991 page.p(class_="controls") 992 page.input(name="newevent", type="submit", value="New event", id="newevent") 993 page.input(name="reset", type="submit", value="Clear selections", id="reset") 994 page.p.close() 995 996 # Show controls for hiding empty days and busy slots. 997 # The positioning of the control, paragraph and table are important here. 998 999 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1000 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1001 1002 page.p(class_="controls") 1003 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1004 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1005 page.label("Show empty days", for_="showdays", class_="showdays disable") 1006 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1007 page.p.close() 1008 1009 freebusy = self.store.get_freebusy(self.user) 1010 1011 if not freebusy: 1012 page.p("No events scheduled.") 1013 return 1014 1015 # Obtain the user's timezone. 1016 1017 tzid = self.get_tzid() 1018 1019 # Day view: start at the earliest known day and produce days until the 1020 # latest known day, perhaps with expandable sections of empty days. 1021 1022 # Month view: start at the earliest known month and produce months until 1023 # the latest known month, perhaps with expandable sections of empty 1024 # months. 1025 1026 # Details of users to invite to new events could be superimposed on the 1027 # calendar. 1028 1029 # Requests are listed and linked to their tentative positions in the 1030 # calendar. Other participants are also shown. 1031 1032 request_summary = self._get_request_summary() 1033 1034 period_groups = [request_summary, freebusy] 1035 period_group_types = ["request", "freebusy"] 1036 period_group_sources = ["Pending requests", "Your schedule"] 1037 1038 for i, participant in enumerate(participants): 1039 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1040 period_group_types.append("freebusy-part%d" % i) 1041 period_group_sources.append(participant) 1042 1043 groups = [] 1044 group_columns = [] 1045 group_types = period_group_types 1046 group_sources = period_group_sources 1047 all_points = set() 1048 1049 # Obtain time point information for each group of periods. 1050 1051 for periods in period_groups: 1052 periods = convert_periods(periods, tzid) 1053 1054 # Get the time scale with start and end points. 1055 1056 scale = get_scale(periods) 1057 1058 # Get the time slots for the periods. 1059 1060 slots = get_slots(scale) 1061 1062 # Add start of day time points for multi-day periods. 1063 1064 add_day_start_points(slots, tzid) 1065 1066 # Record the slots and all time points employed. 1067 1068 groups.append(slots) 1069 all_points.update([point for point, active in slots]) 1070 1071 # Partition the groups into days. 1072 1073 days = {} 1074 partitioned_groups = [] 1075 partitioned_group_types = [] 1076 partitioned_group_sources = [] 1077 1078 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1079 1080 # Propagate time points to all groups of time slots. 1081 1082 add_slots(slots, all_points) 1083 1084 # Count the number of columns employed by the group. 1085 1086 columns = 0 1087 1088 # Partition the time slots by day. 1089 1090 partitioned = {} 1091 1092 for day, day_slots in partition_by_day(slots).items(): 1093 intervals = [] 1094 last = None 1095 1096 for point, active in day_slots: 1097 columns = max(columns, len(active)) 1098 if last: 1099 intervals.append((last, point)) 1100 last = point 1101 1102 if last: 1103 intervals.append((last, None)) 1104 1105 if not days.has_key(day): 1106 days[day] = set() 1107 1108 # Convert each partition to a mapping from points to active 1109 # periods. 1110 1111 partitioned[day] = dict(day_slots) 1112 1113 # Record the divisions or intervals within each day. 1114 1115 days[day].update(intervals) 1116 1117 if group_type != "request" or columns: 1118 group_columns.append(columns) 1119 partitioned_groups.append(partitioned) 1120 partitioned_group_types.append(group_type) 1121 partitioned_group_sources.append(group_source) 1122 1123 # Add empty days. 1124 1125 add_empty_days(days, tzid) 1126 1127 # Show the controls permitting day selection. 1128 1129 self.show_calendar_day_controls(days) 1130 1131 # Show the calendar itself. 1132 1133 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1134 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1135 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1136 page.table.close() 1137 1138 # End the form region. 1139 1140 page.form.close() 1141 1142 # More page fragment methods. 1143 1144 def show_calendar_day_controls(self, days): 1145 1146 "Show controls for the given 'days' in the calendar." 1147 1148 page = self.page 1149 slots = self.env.get_args().get("slot", []) 1150 1151 for day in days: 1152 value, identifier = self._day_value_and_identifier(day) 1153 self._slot_selector(value, identifier, slots) 1154 1155 # Generate a dynamic stylesheet to allow day selections to colour 1156 # specific days. 1157 # NOTE: The style details need to be coordinated with the static 1158 # NOTE: stylesheet. 1159 1160 page.style(type="text/css") 1161 1162 for day in days: 1163 daystr = format_datetime(day) 1164 page.add("""\ 1165 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1166 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1167 background-color: #5f4; 1168 text-decoration: underline; 1169 } 1170 """ % (daystr, daystr, daystr, daystr)) 1171 1172 page.style.close() 1173 1174 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1175 1176 """ 1177 Show headings for the participants and other scheduling contributors, 1178 defined by 'group_types', 'group_sources' and 'group_columns'. 1179 """ 1180 1181 page = self.page 1182 1183 page.colgroup(span=1, id="columns-timeslot") 1184 1185 for group_type, columns in zip(group_types, group_columns): 1186 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1187 1188 page.thead() 1189 page.tr() 1190 page.th("", class_="emptyheading") 1191 1192 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1193 page.th(source, 1194 class_=(group_type == "request" and "requestheading" or "participantheading"), 1195 colspan=max(columns, 1)) 1196 1197 page.tr.close() 1198 page.thead.close() 1199 1200 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1201 1202 """ 1203 Show calendar days, defined by a collection of 'days', the contributing 1204 period information as 'partitioned_groups' (partitioned by day), the 1205 'partitioned_group_types' indicating the kind of contribution involved, 1206 and the 'group_columns' defining the number of columns in each group. 1207 """ 1208 1209 page = self.page 1210 1211 # Determine the number of columns required. Where participants provide 1212 # no columns for events, one still needs to be provided for the 1213 # participant itself. 1214 1215 all_columns = sum([max(columns, 1) for columns in group_columns]) 1216 1217 # Determine the days providing time slots. 1218 1219 all_days = days.items() 1220 all_days.sort() 1221 1222 # Produce a heading and time points for each day. 1223 1224 for day, intervals in all_days: 1225 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1226 is_empty = True 1227 1228 for slots in groups_for_day: 1229 if not slots: 1230 continue 1231 1232 for active in slots.values(): 1233 if active: 1234 is_empty = False 1235 break 1236 1237 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1238 page.tr() 1239 page.th(class_="dayheading container", colspan=all_columns+1) 1240 self._day_heading(day) 1241 page.th.close() 1242 page.tr.close() 1243 page.thead.close() 1244 1245 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1246 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1247 page.tbody.close() 1248 1249 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1250 1251 """ 1252 Show the time 'intervals' along with period information from the given 1253 'groups', having the indicated 'group_types', each with the number of 1254 columns given by 'group_columns'. 1255 """ 1256 1257 page = self.page 1258 1259 # Obtain the user's timezone. 1260 1261 tzid = self.get_tzid() 1262 1263 # Produce a row for each interval. 1264 1265 intervals = list(intervals) 1266 intervals.sort() 1267 1268 for point, endpoint in intervals: 1269 continuation = point == get_start_of_day(point, tzid) 1270 1271 # Some rows contain no period details and are marked as such. 1272 1273 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1274 1275 css = " ".join( 1276 ["slot"] + 1277 (have_active and ["busy"] or ["empty"]) + 1278 (continuation and ["daystart"] or []) 1279 ) 1280 1281 page.tr(class_=css) 1282 page.th(class_="timeslot") 1283 self._time_point(point, endpoint) 1284 page.th.close() 1285 1286 # Obtain slots for the time point from each group. 1287 1288 for columns, slots, group_type in zip(group_columns, groups, group_types): 1289 active = slots and slots.get(point) 1290 1291 # Where no periods exist for the given time interval, generate 1292 # an empty cell. Where a participant provides no periods at all, 1293 # the colspan is adjusted to be 1, not 0. 1294 1295 if not active: 1296 page.td(class_="empty container", colspan=max(columns, 1)) 1297 self._empty_slot(point, endpoint) 1298 page.td.close() 1299 continue 1300 1301 slots = slots.items() 1302 slots.sort() 1303 spans = get_spans(slots) 1304 1305 empty = 0 1306 1307 # Show a column for each active period. 1308 1309 for t in active: 1310 if t and len(t) >= 2: 1311 1312 # Flush empty slots preceding this one. 1313 1314 if empty: 1315 page.td(class_="empty container", colspan=empty) 1316 self._empty_slot(point, endpoint) 1317 page.td.close() 1318 empty = 0 1319 1320 start, end, uid, key = get_freebusy_details(t) 1321 span = spans[key] 1322 1323 # Produce a table cell only at the start of the period 1324 # or when continued at the start of a day. 1325 1326 if point == start or continuation: 1327 1328 obj = self._get_object(uid) 1329 1330 has_continued = continuation and point != start 1331 will_continue = not ends_on_same_day(point, end, tzid) 1332 is_organiser = obj and obj.get_value("ORGANIZER") == self.user 1333 1334 css = " ".join( 1335 ["event"] + 1336 (has_continued and ["continued"] or []) + 1337 (will_continue and ["continues"] or []) + 1338 (is_organiser and ["organising"] or ["attending"]) 1339 ) 1340 1341 # Only anchor the first cell of events. 1342 1343 if point == start: 1344 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1345 else: 1346 page.td(class_=css, rowspan=span) 1347 1348 if not obj: 1349 page.span("(Participant is busy)") 1350 else: 1351 summary = obj.get_value("SUMMARY") 1352 1353 # Only link to events if they are not being 1354 # updated by requests. 1355 1356 if uid in self._get_requests() and group_type != "request": 1357 page.span(summary) 1358 else: 1359 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1360 page.a(summary, href=href) 1361 1362 page.td.close() 1363 else: 1364 empty += 1 1365 1366 # Pad with empty columns. 1367 1368 empty = columns - len(active) 1369 1370 if empty: 1371 page.td(class_="empty container", colspan=empty) 1372 self._empty_slot(point, endpoint) 1373 page.td.close() 1374 1375 page.tr.close() 1376 1377 def _day_heading(self, day): 1378 1379 """ 1380 Generate a heading for 'day' of the following form: 1381 1382 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1383 """ 1384 1385 page = self.page 1386 daystr = format_datetime(day) 1387 value, identifier = self._day_value_and_identifier(day) 1388 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1389 1390 def _time_point(self, point, endpoint): 1391 1392 """ 1393 Generate headings for the 'point' to 'endpoint' period of the following 1394 form: 1395 1396 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1397 <span class="endpoint">10:00:00 CET</span> 1398 """ 1399 1400 page = self.page 1401 tzid = self.get_tzid() 1402 daystr = format_datetime(point.date()) 1403 value, identifier = self._slot_value_and_identifier(point, endpoint) 1404 slots = self.env.get_args().get("slot", []) 1405 self._slot_selector(value, identifier, slots) 1406 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1407 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1408 1409 def _slot_selector(self, value, identifier, slots): 1410 reset = self.env.get_args().has_key("reset") 1411 page = self.page 1412 if not reset and value in slots: 1413 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1414 else: 1415 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1416 1417 def _empty_slot(self, point, endpoint): 1418 page = self.page 1419 value, identifier = self._slot_value_and_identifier(point, endpoint) 1420 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1421 1422 def _day_value_and_identifier(self, day): 1423 value = "%s-" % format_datetime(day) 1424 identifier = "day-%s" % value 1425 return value, identifier 1426 1427 def _slot_value_and_identifier(self, point, endpoint): 1428 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1429 identifier = "slot-%s" % value 1430 return value, identifier 1431 1432 def _show_menu(self, name, default, items): 1433 page = self.page 1434 values = self.env.get_args().get(name, [default]) 1435 page.select(name=name) 1436 for v, label in items: 1437 if v in values: 1438 page.option(label, value=v, selected="selected") 1439 else: 1440 page.option(label, value=v) 1441 page.select.close() 1442 1443 def _show_date_controls(self, name, default, attr, tzid): 1444 1445 """ 1446 Show date controls for a field with the given 'name' and 'default' value 1447 and 'attr', with the given 'tzid' being used if no other time regime 1448 information is provided. 1449 """ 1450 1451 page = self.page 1452 args = self.env.get_args() 1453 1454 event_tzid = attr.get("TZID", tzid) 1455 dt = get_datetime(default, attr) 1456 1457 # Show dates for up to one week around the current date. 1458 1459 base = get_date(dt) 1460 items = [] 1461 for i in range(-7, 8): 1462 d = base + timedelta(i) 1463 items.append((format_datetime(d), self.format_date(d, "full"))) 1464 1465 self._show_menu("%s-date" % name, format_datetime(base), items) 1466 1467 # Show time details. 1468 1469 if isinstance(dt, datetime): 1470 hour = args.get("%s-hour" % name, "%02d" % dt.hour) 1471 minute = args.get("%s-minute" % name, "%02d" % dt.minute) 1472 second = args.get("%s-second" % name, "%02d" % dt.second) 1473 page.add(" ") 1474 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1475 page.add(":") 1476 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1477 page.add(":") 1478 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1479 page.add(" ") 1480 self._show_menu("%s-tzid" % name, event_tzid, 1481 [(event_tzid, event_tzid)] + ( 1482 event_tzid != tzid and [(tzid, tzid)] or [] 1483 )) 1484 1485 # Incoming HTTP request direction. 1486 1487 def select_action(self): 1488 1489 "Select the desired action and show the result." 1490 1491 path_info = self.env.get_path_info().strip("/") 1492 1493 if not path_info: 1494 self.show_calendar() 1495 elif self.show_object(path_info): 1496 pass 1497 else: 1498 self.no_page() 1499 1500 def __call__(self): 1501 1502 "Interpret a request and show an appropriate response." 1503 1504 if not self.user: 1505 self.no_user() 1506 else: 1507 self.select_action() 1508 1509 # Write the headers and actual content. 1510 1511 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1512 print >>self.out 1513 self.out.write(unicode(self.page).encode(self.encoding)) 1514 1515 if __name__ == "__main__": 1516 Manager()() 1517 1518 # vim: tabstop=4 expandtab shiftwidth=4