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_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 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 attendees = obj.get_value_map("ATTENDEE") 478 if attendees.has_key(self.user): 479 attendees[self.user]["PARTSTAT"] = args["partstat"][0] 480 481 # Process any action. 482 483 reply = args.has_key("reply") 484 discard = args.has_key("discard") 485 invite = args.has_key("invite") 486 cancel = args.has_key("cancel") 487 save = args.has_key("save") 488 update = not queued and args.has_key("update") 489 490 if reply or invite or cancel: 491 492 handler = ManagerHandler(obj, self.user, self.messenger) 493 494 # Process the object and remove it from the list of requests. 495 496 if reply and handler.process_received_request(update) or \ 497 (invite or cancel) and handler.process_created_request(invite and "REQUEST" or "CANCEL", update): 498 499 self.remove_request(uid) 500 501 # Save single user events. 502 503 elif save: 504 self.store.set_event(self.user, uid, obj.to_node()) 505 freebusy = self.store.get_freebusy(self.user) 506 self.remove_request(uid) 507 508 # Remove the request and the object. 509 510 elif discard: 511 self.remove_event(uid) 512 self.remove_request(uid) 513 514 else: 515 handled = False 516 517 # Upon handling an action, redirect to the main page. 518 519 if handled: 520 self.redirect(self.env.get_path()) 521 522 return handled 523 524 # Page fragment methods. 525 526 def show_request_controls(self, obj, needs_update): 527 528 """ 529 Show form controls for a request concerning 'obj', indicating whether 530 an update will be performed if 'needs_update' is specified as a true 531 value. 532 """ 533 534 page = self.page 535 536 is_organiser = obj.get_value("ORGANIZER") == self.user 537 538 attendees = obj.get_value_map("ATTENDEE") 539 is_attendee = attendees.has_key(self.user) 540 attendee_attr = attendees.get(self.user) 541 542 is_request = obj.get_value("UID") in self._get_requests() 543 544 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 545 546 # Show appropriate options depending on the role of the user. 547 548 if is_attendee and not is_organiser: 549 partstat = attendee_attr.get("PARTSTAT") 550 if not partstat: 551 page.p("This request has not yet been dealt with.") 552 553 if needs_update: 554 page.p("This request can be updated as follows:") 555 else: 556 page.p("An action is required for this request:") 557 558 page.p() 559 page.input(name="reply", type="submit", value="Reply") 560 page.add(" ") 561 page.input(name="discard", type="submit", value="Discard") 562 page.p.close() 563 564 if is_organiser: 565 if have_other_attendees: 566 if needs_update: 567 page.p("As organiser, you can perform the following:") 568 else: 569 page.p("As organiser, you will need to perform an action:") 570 571 page.p() 572 page.input(name="invite", type="submit", value="Invite") 573 page.add(" ") 574 if is_request: 575 page.input(name="discard", type="submit", value="Discard") 576 else: 577 page.input(name="cancel", type="submit", value="Cancel") 578 page.p.close() 579 else: 580 page.p() 581 page.input(name="save", type="submit", value="Save") 582 page.add(" ") 583 page.input(name="discard", type="submit", value="Discard") 584 page.p.close() 585 586 # Updated objects need to have details updated upon sending. 587 588 if needs_update: 589 page.input(name="update", type="hidden", value="true") 590 591 object_labels = { 592 "SUMMARY" : "Summary", 593 "DTSTART" : "Start", 594 "DTEND" : "End", 595 "ORGANIZER" : "Organiser", 596 "ATTENDEE" : "Attendee", 597 } 598 599 partstat_items = [ 600 ("NEEDS-ACTION", "Not confirmed"), 601 ("ACCEPTED", "Attending"), 602 ("TENTATIVE", "Tentatively attending"), 603 ("DECLINED", "Not attending"), 604 ] 605 606 def show_object_on_page(self, uid, obj, needs_update): 607 608 """ 609 Show the calendar object with the given 'uid' and representation 'obj' 610 on the current page. 611 """ 612 613 page = self.page 614 page.form(method="POST") 615 616 # Obtain the user's timezone. 617 618 tzid = self.get_tzid() 619 620 # Provide a summary of the object. 621 622 page.table(class_="object", cellspacing=5, cellpadding=5) 623 page.thead() 624 page.tr() 625 page.th("Event", class_="mainheading", colspan=3) 626 page.tr.close() 627 page.thead.close() 628 page.tbody() 629 630 is_organiser = obj.get_value("ORGANIZER") == self.user 631 632 for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]: 633 page.tr() 634 635 label = self.object_labels.get(name, name) 636 637 # Handle datetimes specially. 638 639 if name in ["DTSTART", "DTEND"]: 640 value, attr = obj.get_item(name) 641 tzid = attr.get("TZID", tzid) 642 value = ( 643 name == "DTSTART" and self.format_datetime or self.format_end_datetime 644 )(to_timezone(get_datetime(value), tzid), "full") 645 page.th(label, class_="objectheading") 646 page.td(value, colspan=2) 647 page.tr.close() 648 649 # Handle the summary specially. 650 651 elif name == "SUMMARY": 652 value = obj.get_value(name) 653 page.th(label, class_="objectheading") 654 page.td(colspan=2) 655 if is_organiser: 656 page.input(name="summary", type="text", value=value, size=80) 657 else: 658 page.add(value) 659 page.td.close() 660 page.tr.close() 661 662 # Handle potentially many values. 663 664 else: 665 items = obj.get_items(name) 666 if not items: 667 continue 668 669 page.th(label, class_="objectheading", rowspan=len(items)) 670 671 first = True 672 673 for value, attr in items: 674 if not first: 675 page.tr() 676 else: 677 first = False 678 679 if name == "ATTENDEE": 680 page.td(class_="objectattribute") 681 page.add(value) 682 page.td.close() 683 page.td(class_="partstat") 684 685 partstat = attr.get("PARTSTAT") 686 if value == self.user: 687 self._show_menu("partstat", partstat, self.partstat_items) 688 else: 689 page.add(dict(self.partstat_items).get(partstat, "")) 690 else: 691 page.td(class_="objectattribute", colspan=2) 692 page.add(value) 693 694 page.td.close() 695 page.tr.close() 696 697 page.tbody.close() 698 page.table.close() 699 700 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 701 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 702 703 # Indicate whether there are conflicting events. 704 705 freebusy = self.store.get_freebusy(self.user) 706 707 if freebusy: 708 709 # Obtain any time zone details from the suggested event. 710 711 _dtstart, attr = obj.get_item("DTSTART") 712 tzid = attr.get("TZID", tzid) 713 714 # Show any conflicts. 715 716 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 717 start, end, found_uid = t[:3] 718 719 # Provide details of any conflicting event. 720 721 if uid != found_uid: 722 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 723 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 724 page.p("Event conflicts with another from %s to %s: " % (start, end)) 725 726 # Show the event summary for the conflicting event. 727 728 found_obj = self._get_object(found_uid) 729 if found_obj: 730 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 731 732 self.show_request_controls(obj, needs_update) 733 page.form.close() 734 735 def show_requests_on_page(self): 736 737 "Show requests for the current user." 738 739 # NOTE: This list could be more informative, but it is envisaged that 740 # NOTE: the requests would be visited directly anyway. 741 742 requests = self._get_requests() 743 744 self.page.div(id="pending-requests") 745 746 if requests: 747 self.page.p("Pending requests:") 748 749 self.page.ul() 750 751 for request in requests: 752 obj = self._get_object(request) 753 if obj: 754 self.page.li() 755 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 756 self.page.li.close() 757 758 self.page.ul.close() 759 760 else: 761 self.page.p("There are no pending requests.") 762 763 self.page.div.close() 764 765 def show_participants_on_page(self): 766 767 "Show participants for scheduling purposes." 768 769 args = self.env.get_args() 770 participants = args.get("participants", []) 771 772 try: 773 for name, value in args.items(): 774 if name.startswith("remove-participant-"): 775 i = int(name[len("remove-participant-"):]) 776 del participants[i] 777 break 778 except ValueError: 779 pass 780 781 # Trim empty participants. 782 783 while participants and not participants[-1].strip(): 784 participants.pop() 785 786 # Show any specified participants together with controls to remove and 787 # add participants. 788 789 self.page.div(id="participants") 790 791 self.page.p("Participants for scheduling:") 792 793 for i, participant in enumerate(participants): 794 self.page.p() 795 self.page.input(name="participants", type="text", value=participant) 796 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 797 self.page.p.close() 798 799 self.page.p() 800 self.page.input(name="participants", type="text") 801 self.page.input(name="add-participant", type="submit", value="Add") 802 self.page.p.close() 803 804 self.page.div.close() 805 806 return participants 807 808 # Full page output methods. 809 810 def show_object(self, path_info): 811 812 "Show an object request using the given 'path_info' for the current user." 813 814 uid = self._get_uid(path_info) 815 obj = self._get_object(uid) 816 817 if not obj: 818 return False 819 820 is_request = uid in self._get_requests() 821 handled = self.handle_request(uid, obj, is_request) 822 823 if handled: 824 return True 825 826 self.new_page(title="Event") 827 self.show_object_on_page(uid, obj, not is_request) 828 829 return True 830 831 def show_calendar(self): 832 833 "Show the calendar for the current user." 834 835 handled = self.handle_newevent() 836 837 self.new_page(title="Calendar") 838 page = self.page 839 840 # Form controls are used in various places on the calendar page. 841 842 page.form(method="POST") 843 844 self.show_requests_on_page() 845 participants = self.show_participants_on_page() 846 847 # Show a button for scheduling a new event. 848 849 page.p(class_="controls") 850 page.input(name="newevent", type="submit", value="New event", id="newevent") 851 page.input(name="reset", type="submit", value="Clear selections", id="reset") 852 page.p.close() 853 854 # Show controls for hiding empty and busy slots. 855 # The positioning of the control, paragraph and table are important here. 856 857 page.input(name="hideslots", type="checkbox", value="hide", id="hideslots") 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 unused time periods", for_="hideslots", class_="hideslots enable") 864 page.label("Show unused time periods", for_="hideslots", class_="hideslots disable") 865 page.p.close() 866 867 freebusy = self.store.get_freebusy(self.user) 868 869 if not freebusy: 870 page.p("No events scheduled.") 871 return 872 873 # Obtain the user's timezone. 874 875 tzid = self.get_tzid() 876 877 # Day view: start at the earliest known day and produce days until the 878 # latest known day, perhaps with expandable sections of empty days. 879 880 # Month view: start at the earliest known month and produce months until 881 # the latest known month, perhaps with expandable sections of empty 882 # months. 883 884 # Details of users to invite to new events could be superimposed on the 885 # calendar. 886 887 # Requests are listed and linked to their tentative positions in the 888 # calendar. Other participants are also shown. 889 890 request_summary = self._get_request_summary() 891 892 period_groups = [request_summary, freebusy] 893 period_group_types = ["request", "freebusy"] 894 period_group_sources = ["Pending requests", "Your schedule"] 895 896 for i, participant in enumerate(participants): 897 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 898 period_group_types.append("freebusy-part%d" % i) 899 period_group_sources.append(participant) 900 901 groups = [] 902 group_columns = [] 903 group_types = period_group_types 904 group_sources = period_group_sources 905 all_points = set() 906 907 # Obtain time point information for each group of periods. 908 909 for periods in period_groups: 910 periods = convert_periods(periods, tzid) 911 912 # Get the time scale with start and end points. 913 914 scale = get_scale(periods) 915 916 # Get the time slots for the periods. 917 918 slots = get_slots(scale) 919 920 # Add start of day time points for multi-day periods. 921 922 add_day_start_points(slots, tzid) 923 924 # Record the slots and all time points employed. 925 926 groups.append(slots) 927 all_points.update([point for point, active in slots]) 928 929 # Partition the groups into days. 930 931 days = {} 932 partitioned_groups = [] 933 partitioned_group_types = [] 934 partitioned_group_sources = [] 935 936 for slots, group_type, group_source in zip(groups, group_types, group_sources): 937 938 # Propagate time points to all groups of time slots. 939 940 add_slots(slots, all_points) 941 942 # Count the number of columns employed by the group. 943 944 columns = 0 945 946 # Partition the time slots by day. 947 948 partitioned = {} 949 950 for day, day_slots in partition_by_day(slots).items(): 951 intervals = [] 952 last = None 953 954 for point, active in day_slots: 955 columns = max(columns, len(active)) 956 if last: 957 intervals.append((last, point)) 958 last = point 959 960 if last: 961 intervals.append((last, None)) 962 963 if not days.has_key(day): 964 days[day] = set() 965 966 # Convert each partition to a mapping from points to active 967 # periods. 968 969 partitioned[day] = dict(day_slots) 970 971 # Record the divisions or intervals within each day. 972 973 days[day].update(intervals) 974 975 if group_type != "request" or columns: 976 group_columns.append(columns) 977 partitioned_groups.append(partitioned) 978 partitioned_group_types.append(group_type) 979 partitioned_group_sources.append(group_source) 980 981 self.show_calendar_day_controls(days) 982 983 page.table(cellspacing=5, cellpadding=5, class_="calendar") 984 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 985 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 986 page.table.close() 987 988 # End the form region. 989 990 page.form.close() 991 992 # More page fragment methods. 993 994 def show_calendar_day_controls(self, days): 995 996 "Show controls for the given 'days' in the calendar." 997 998 page = self.page 999 slots = self.env.get_args().get("slot", []) 1000 1001 for day in days: 1002 value, identifier = self._day_value_and_identifier(day) 1003 self._slot_selector(value, identifier, slots) 1004 1005 # Generate a dynamic stylesheet to allow day selections to colour 1006 # specific days. 1007 # NOTE: The style details need to be coordinated with the static 1008 # NOTE: stylesheet. 1009 1010 page.style(type="text/css") 1011 1012 for day in days: 1013 daystr = format_datetime(day) 1014 page.add("""\ 1015 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1016 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1017 background-color: #5f4; 1018 text-decoration: underline; 1019 } 1020 """ % (daystr, daystr, daystr, daystr)) 1021 1022 page.style.close() 1023 1024 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1025 1026 """ 1027 Show headings for the participants and other scheduling contributors, 1028 defined by 'group_types', 'group_sources' and 'group_columns'. 1029 """ 1030 1031 page = self.page 1032 1033 page.colgroup(span=1, id="columns-timeslot") 1034 1035 for group_type, columns in zip(group_types, group_columns): 1036 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1037 1038 page.thead() 1039 page.tr() 1040 page.th("", class_="emptyheading") 1041 1042 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1043 page.th(source, 1044 class_=(group_type == "request" and "requestheading" or "participantheading"), 1045 colspan=max(columns, 1)) 1046 1047 page.tr.close() 1048 page.thead.close() 1049 1050 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1051 1052 """ 1053 Show calendar days, defined by a collection of 'days', the contributing 1054 period information as 'partitioned_groups' (partitioned by day), the 1055 'partitioned_group_types' indicating the kind of contribution involved, 1056 and the 'group_columns' defining the number of columns in each group. 1057 """ 1058 1059 page = self.page 1060 1061 # Determine the number of columns required. Where participants provide 1062 # no columns for events, one still needs to be provided for the 1063 # participant itself. 1064 1065 all_columns = sum([max(columns, 1) for columns in group_columns]) 1066 1067 # Determine the days providing time slots. 1068 1069 all_days = days.items() 1070 all_days.sort() 1071 1072 # Produce a heading and time points for each day. 1073 1074 for day, intervals in all_days: 1075 page.thead() 1076 page.tr() 1077 page.th(class_="dayheading container", colspan=all_columns+1) 1078 self._day_heading(day) 1079 page.th.close() 1080 page.tr.close() 1081 page.thead.close() 1082 1083 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1084 1085 page.tbody() 1086 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1087 page.tbody.close() 1088 1089 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1090 1091 """ 1092 Show the time 'intervals' along with period information from the given 1093 'groups', having the indicated 'group_types', each with the number of 1094 columns given by 'group_columns'. 1095 """ 1096 1097 page = self.page 1098 1099 # Obtain the user's timezone. 1100 1101 tzid = self.get_tzid() 1102 1103 # Produce a row for each interval. 1104 1105 intervals = list(intervals) 1106 intervals.sort() 1107 1108 for point, endpoint in intervals: 1109 continuation = point == get_start_of_day(point, tzid) 1110 1111 # Some rows contain no period details and are marked as such. 1112 1113 have_active = reduce(lambda x, y: x or y, [slots.get(point) for slots in groups], None) 1114 1115 css = " ".join( 1116 ["slot"] + 1117 (have_active and ["busy"] or ["empty"]) + 1118 (continuation and ["daystart"] or []) 1119 ) 1120 1121 page.tr(class_=css) 1122 page.th(class_="timeslot") 1123 self._time_point(point, endpoint) 1124 page.th.close() 1125 1126 # Obtain slots for the time point from each group. 1127 1128 for columns, slots, group_type in zip(group_columns, groups, group_types): 1129 active = slots and slots.get(point) 1130 1131 # Where no periods exist for the given time interval, generate 1132 # an empty cell. Where a participant provides no periods at all, 1133 # the colspan is adjusted to be 1, not 0. 1134 1135 if not active: 1136 page.td(class_="empty container", colspan=max(columns, 1)) 1137 self._empty_slot(point, endpoint) 1138 page.td.close() 1139 continue 1140 1141 slots = slots.items() 1142 slots.sort() 1143 spans = get_spans(slots) 1144 1145 # Show a column for each active period. 1146 1147 for t in active: 1148 if t and len(t) >= 2: 1149 start, end, uid, key = get_freebusy_details(t) 1150 span = spans[key] 1151 1152 # Produce a table cell only at the start of the period 1153 # or when continued at the start of a day. 1154 1155 if point == start or continuation: 1156 1157 obj = self._get_object(uid) 1158 1159 has_continued = continuation and point != start 1160 will_continue = not ends_on_same_day(point, end, tzid) 1161 is_organiser = obj.get_value("ORGANIZER") == self.user 1162 1163 css = " ".join( 1164 ["event"] + 1165 (has_continued and ["continued"] or []) + 1166 (will_continue and ["continues"] or []) + 1167 (is_organiser and ["organising"] or ["attending"]) 1168 ) 1169 1170 # Only anchor the first cell of events. 1171 1172 if point == start: 1173 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1174 else: 1175 page.td(class_=css, rowspan=span) 1176 1177 if not obj: 1178 page.span("") 1179 else: 1180 summary = obj.get_value("SUMMARY") 1181 1182 # Only link to events if they are not being 1183 # updated by requests. 1184 1185 if uid in self._get_requests() and group_type != "request": 1186 page.span(summary) 1187 else: 1188 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1189 page.a(summary, href=href) 1190 1191 page.td.close() 1192 else: 1193 page.td(class_="empty container") 1194 self._empty_slot(point, endpoint) 1195 page.td.close() 1196 1197 # Pad with empty columns. 1198 1199 i = columns - len(active) 1200 while i > 0: 1201 i -= 1 1202 page.td(class_="empty container") 1203 self._empty_slot(point, endpoint) 1204 page.td.close() 1205 1206 page.tr.close() 1207 1208 def _day_heading(self, day): 1209 1210 """ 1211 Generate a heading for 'day' of the following form: 1212 1213 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1214 """ 1215 1216 page = self.page 1217 daystr = format_datetime(day) 1218 value, identifier = self._day_value_and_identifier(day) 1219 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1220 1221 def _time_point(self, point, endpoint): 1222 1223 """ 1224 Generate headings for the 'point' to 'endpoint' period of the following 1225 form: 1226 1227 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1228 <span class="endpoint">10:00:00 CET</span> 1229 """ 1230 1231 page = self.page 1232 tzid = self.get_tzid() 1233 daystr = format_datetime(point.date()) 1234 value, identifier = self._slot_value_and_identifier(point, endpoint) 1235 slots = self.env.get_args().get("slot", []) 1236 self._slot_selector(value, identifier, slots) 1237 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1238 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1239 1240 def _slot_selector(self, value, identifier, slots): 1241 reset = self.env.get_args().has_key("reset") 1242 page = self.page 1243 if not reset and value in slots: 1244 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1245 else: 1246 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1247 1248 def _empty_slot(self, point, endpoint): 1249 page = self.page 1250 value, identifier = self._slot_value_and_identifier(point, endpoint) 1251 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1252 1253 def _day_value_and_identifier(self, day): 1254 value = "%s-" % format_datetime(day) 1255 identifier = "day-%s" % value 1256 return value, identifier 1257 1258 def _slot_value_and_identifier(self, point, endpoint): 1259 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1260 identifier = "slot-%s" % value 1261 return value, identifier 1262 1263 def _show_menu(self, name, value, items): 1264 page = self.page 1265 values = self.env.get_args().get(name, [value]) 1266 page.select(name=name) 1267 for v, label in items: 1268 if v in values: 1269 page.option(label, value=v, selected="selected") 1270 else: 1271 page.option(label, value=v) 1272 page.select.close() 1273 1274 # Incoming HTTP request direction. 1275 1276 def select_action(self): 1277 1278 "Select the desired action and show the result." 1279 1280 path_info = self.env.get_path_info().strip("/") 1281 1282 if not path_info: 1283 self.show_calendar() 1284 elif self.show_object(path_info): 1285 pass 1286 else: 1287 self.no_page() 1288 1289 def __call__(self): 1290 1291 "Interpret a request and show an appropriate response." 1292 1293 if not self.user: 1294 self.no_user() 1295 else: 1296 self.select_action() 1297 1298 # Write the headers and actual content. 1299 1300 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1301 print >>self.out 1302 self.out.write(unicode(self.page).encode(self.encoding)) 1303 1304 if __name__ == "__main__": 1305 Manager()() 1306 1307 # vim: tabstop=4 expandtab shiftwidth=4