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