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