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