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