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