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