1 #!/usr/bin/env python 2 3 """ 4 A Web interface to a user's calendar. 5 6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 # Edit this path to refer to the location of the imiptools libraries, if 23 # necessary. 24 25 LIBRARY_PATH = "/var/lib/imip-agent" 26 27 from datetime import date, datetime, timedelta 28 import babel.dates 29 import cgi, os, sys 30 31 sys.path.append(LIBRARY_PATH) 32 33 from imiptools.content import Handler 34 from imiptools.data import get_address, get_uri, make_freebusy, parse_object, \ 35 Object, to_part 36 from imiptools.dates import format_datetime, get_datetime, get_datetime_item, \ 37 get_end_of_day, get_start_of_day, get_start_of_next_day, \ 38 get_timestamp, ends_on_same_day, to_timezone 39 from imiptools.mail import Messenger 40 from imiptools.period import add_day_start_points, add_slots, convert_periods, \ 41 get_freebusy_details, \ 42 get_scale, have_conflict, get_slots, get_spans, \ 43 partition_by_day 44 from imiptools.profile import Preferences 45 import imip_store 46 import markup 47 48 getenv = os.environ.get 49 setenv = os.environ.__setitem__ 50 51 class CGIEnvironment: 52 53 "A CGI-compatible environment." 54 55 def __init__(self, charset=None): 56 self.charset = charset 57 self.args = None 58 self.method = None 59 self.path = None 60 self.path_info = None 61 self.user = None 62 63 def get_args(self): 64 if self.args is None: 65 if self.get_method() != "POST": 66 setenv("QUERY_STRING", "") 67 args = cgi.parse(keep_blank_values=True) 68 69 if not self.charset: 70 self.args = args 71 else: 72 self.args = {} 73 for key, values in args.items(): 74 self.args[key] = [unicode(value, self.charset) for value in values] 75 76 return self.args 77 78 def get_method(self): 79 if self.method is None: 80 self.method = getenv("REQUEST_METHOD") or "GET" 81 return self.method 82 83 def get_path(self): 84 if self.path is None: 85 self.path = getenv("SCRIPT_NAME") or "" 86 return self.path 87 88 def get_path_info(self): 89 if self.path_info is None: 90 self.path_info = getenv("PATH_INFO") or "" 91 return self.path_info 92 93 def get_user(self): 94 if self.user is None: 95 self.user = getenv("REMOTE_USER") or "" 96 return self.user 97 98 def get_output(self): 99 return sys.stdout 100 101 def get_url(self): 102 path = self.get_path() 103 path_info = self.get_path_info() 104 return "%s%s" % (path.rstrip("/"), path_info) 105 106 def new_url(self, path_info): 107 path = self.get_path() 108 return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/")) 109 110 class ManagerHandler(Handler): 111 112 """ 113 A content handler for use by the manager, as opposed to operating within the 114 mail processing pipeline. 115 """ 116 117 def __init__(self, obj, user, messenger): 118 Handler.__init__(self, messenger=messenger) 119 self.set_object(obj) 120 self.user = user 121 122 self.organiser = self.obj.get_value("ORGANIZER") 123 self.attendees = self.obj.get_values("ATTENDEE") 124 125 # Communication methods. 126 127 def send_message(self, method, sender): 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 # Prettyprinting of dates and times. 290 291 def format_date(self, dt, format): 292 return self._format_datetime(babel.dates.format_date, dt, format) 293 294 def format_time(self, dt, format): 295 return self._format_datetime(babel.dates.format_time, dt, format) 296 297 def format_datetime(self, dt, format): 298 return self._format_datetime( 299 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 300 dt, format) 301 302 def format_end_datetime(self, dt, format): 303 if isinstance(dt, date) and not isinstance(dt, datetime): 304 dt = dt - timedelta(1) 305 return self.format_datetime(dt, format) 306 307 def _format_datetime(self, fn, dt, format): 308 return fn(dt, format=format, locale=self.get_user_locale()) 309 310 # Data management methods. 311 312 def remove_request(self, uid): 313 return self.store.dequeue_request(self.user, uid) 314 315 def remove_event(self, uid): 316 return self.store.remove_event(self.user, uid) 317 318 # Presentation methods. 319 320 def new_page(self, title): 321 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 322 323 def status(self, code, message): 324 self.header("Status", "%s %s" % (code, message)) 325 326 def header(self, header, value): 327 print >>self.out, "%s: %s" % (header, value) 328 329 def no_user(self): 330 self.status(403, "Forbidden") 331 self.new_page(title="Forbidden") 332 self.page.p("You are not logged in and thus cannot access scheduling requests.") 333 334 def no_page(self): 335 self.status(404, "Not Found") 336 self.new_page(title="Not Found") 337 self.page.p("No page is provided at the given address.") 338 339 def redirect(self, url): 340 self.status(302, "Redirect") 341 self.header("Location", url) 342 self.new_page(title="Redirect") 343 self.page.p("Redirecting to: %s" % url) 344 345 # Request logic and page fragment methods. 346 347 def handle_newevent(self): 348 349 """ 350 Handle any new event operation, creating a new event and redirecting to 351 the event page for further activity. 352 """ 353 354 # Handle a submitted form. 355 356 args = self.env.get_args() 357 358 if not args.has_key("newevent"): 359 return 360 361 # Create a new event using the available information. 362 363 slots = args.get("slot", []) 364 participants = args.get("participants", []) 365 366 if not slots: 367 return 368 369 # Coalesce the selected slots. 370 371 slots.sort() 372 coalesced = [] 373 last = None 374 375 for slot in slots: 376 start, end = slot.split("-") 377 if last: 378 if start == last[1]: 379 last = last[0], end 380 continue 381 else: 382 coalesced.append(last) 383 last = start, end 384 385 if last: 386 coalesced.append(last) 387 388 # Obtain the user's timezone. 389 390 prefs = self.get_preferences() 391 tzid = prefs.get("TZID", "UTC") 392 393 # Invent a unique identifier. 394 395 utcnow = get_timestamp() 396 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 397 398 # Define a single occurrence if only one coalesced slot exists. 399 # Otherwise, many occurrences are defined. 400 401 for i, (start, end) in enumerate(coalesced): 402 this_uid = "%s-%s" % (uid, i) 403 404 start = get_datetime(start, {"TZID" : tzid}) 405 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 406 407 start_value, start_attr = get_datetime_item(start) 408 end_value, end_attr = get_datetime_item(end) 409 410 # Create a calendar object and store it as a request. 411 412 record = [] 413 rwrite = record.append 414 415 rwrite(("UID", {}, this_uid)) 416 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 417 rwrite(("DTSTAMP", {}, utcnow)) 418 rwrite(("DTSTART", start_attr, start_value)) 419 rwrite(("DTEND", end_attr, end_value)) 420 rwrite(("ORGANIZER", {}, self.user)) 421 422 for participant in participants: 423 if not participant: 424 continue 425 participant = get_uri(participant) 426 if participant != self.user: 427 rwrite(("ATTENDEE", {}, participant)) 428 429 obj = ("VEVENT", {}, record) 430 431 self.store.set_event(self.user, this_uid, obj) 432 self.store.queue_request(self.user, this_uid) 433 434 # Redirect to the object (or the first of the objects), where instead of 435 # attendee controls, there will be organiser controls. 436 437 self.redirect(self.env.new_url("%s-0" % uid)) 438 439 def handle_request(self, uid, obj, queued): 440 441 """ 442 Handle actions involving the given 'uid' and 'obj' object, where 443 'queued' indicates that the object has not yet been handled. 444 """ 445 446 # Handle a submitted form. 447 448 args = self.env.get_args() 449 handled = True 450 451 # Update the object. 452 453 if args.has_key("summary"): 454 obj["SUMMARY"] = [(args["summary"][0], {})] 455 456 # Process any action. 457 458 accept = args.has_key("accept") 459 decline = args.has_key("decline") 460 invite = args.has_key("invite") 461 update = not queued and args.has_key("update") 462 463 if accept or decline or invite: 464 465 handler = ManagerHandler(obj, self.user, self.messenger) 466 467 # Process the object and remove it from the list of requests. 468 469 if (accept or decline) and handler.process_received_request(accept, update) or \ 470 invite and handler.process_created_request(update): 471 472 self.remove_request(uid) 473 474 elif args.has_key("discard"): 475 476 # Remove the request and the object. 477 478 self.remove_event(uid) 479 self.remove_request(uid) 480 481 else: 482 handled = False 483 484 # Upon handling an action, redirect to the main page. 485 486 if handled: 487 self.redirect(self.env.get_path()) 488 489 return handled 490 491 def show_request_controls(self, obj, needs_action): 492 493 """ 494 Show form controls for a request concerning 'obj', indicating whether 495 action is needed if 'needs_action' is specified as a true value. 496 """ 497 498 page = self.page 499 500 is_organiser = obj.get_value("ORGANIZER") == self.user 501 502 if not is_organiser: 503 attendees = obj.get_value_map("ATTENDEE") 504 attendee_attr = attendees.get(self.user) 505 506 if attendee_attr: 507 partstat = attendee_attr.get("PARTSTAT") 508 if partstat == "ACCEPTED": 509 page.p("This request has been accepted.") 510 elif partstat == "DECLINED": 511 page.p("This request has been declined.") 512 else: 513 page.p("This request has not yet been dealt with.") 514 515 if needs_action: 516 page.p("An action is required for this request:") 517 else: 518 page.p("This request can be updated as follows:") 519 520 page.p() 521 522 # Show appropriate options depending on the role of the user. 523 524 if is_organiser: 525 page.input(name="invite", type="submit", value="Invite") 526 else: 527 page.input(name="accept", type="submit", value="Accept") 528 page.add(" ") 529 page.input(name="decline", type="submit", value="Decline") 530 531 page.add(" ") 532 page.input(name="discard", type="submit", value="Discard") 533 534 # Updated objects need to have details updated upon sending. 535 536 if not needs_action: 537 page.input(name="update", type="hidden", value="true") 538 539 page.p.close() 540 541 object_labels = { 542 "SUMMARY" : "Summary", 543 "DTSTART" : "Start", 544 "DTEND" : "End", 545 "ORGANIZER" : "Organiser", 546 "ATTENDEE" : "Attendee", 547 } 548 549 def show_object_on_page(self, uid, obj, needs_action): 550 551 """ 552 Show the calendar object with the given 'uid' and representation 'obj' 553 on the current page. 554 """ 555 556 page = self.page 557 page.form(method="POST") 558 559 # Obtain the user's timezone. 560 561 prefs = self.get_preferences() 562 tzid = prefs.get("TZID", "UTC") 563 564 # Provide a summary of the object. 565 566 page.table(class_="object", cellspacing=5, cellpadding=5) 567 page.thead() 568 page.tr() 569 page.th("Event", class_="mainheading", colspan=2) 570 page.tr.close() 571 page.thead.close() 572 page.tbody() 573 574 for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]: 575 page.tr() 576 577 label = self.object_labels.get(name, name) 578 579 # Handle datetimes specially. 580 581 if name in ["DTSTART", "DTEND"]: 582 value, attr = obj.get_item(name) 583 tzid = attr.get("TZID", tzid) 584 value = ( 585 name == "DTSTART" and self.format_datetime or self.format_end_datetime 586 )(to_timezone(get_datetime(value), tzid), "full") 587 page.th(label, class_="objectheading") 588 page.td(value) 589 page.tr.close() 590 591 # Handle the summary specially. 592 593 elif name == "SUMMARY": 594 value = obj.get_value(name) 595 page.th(label, class_="objectheading") 596 page.td() 597 page.input(name="summary", type="text", value=value, size=80) 598 page.td.close() 599 page.tr.close() 600 601 # Handle potentially many values. 602 603 else: 604 items = obj.get_items(name) 605 if not items: 606 continue 607 608 page.th(label, class_="objectheading", rowspan=len(items)) 609 610 first = True 611 612 for value, attr in items: 613 if not first: 614 page.tr() 615 else: 616 first = False 617 618 page.td() 619 page.add(value) 620 621 if name == "ATTENDEE": 622 partstat = attr.get("PARTSTAT") 623 if partstat: 624 page.add(" (%s)" % partstat) 625 626 page.td.close() 627 page.tr.close() 628 629 page.tbody.close() 630 page.table.close() 631 632 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 633 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 634 635 # Indicate whether there are conflicting events. 636 637 freebusy = self.store.get_freebusy(self.user) 638 639 if freebusy: 640 641 # Obtain any time zone details from the suggested event. 642 643 _dtstart, attr = obj.get_item("DTSTART") 644 tzid = attr.get("TZID", tzid) 645 646 # Show any conflicts. 647 648 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 649 start, end, found_uid = t[:3] 650 651 # Provide details of any conflicting event. 652 653 if uid != found_uid: 654 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 655 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 656 page.p("Event conflicts with another from %s to %s: " % (start, end)) 657 658 # Show the event summary for the conflicting event. 659 660 found_obj = self._get_object(found_uid) 661 if found_obj: 662 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 663 664 self.show_request_controls(obj, needs_action) 665 page.form.close() 666 667 def show_requests_on_page(self): 668 669 "Show requests for the current user." 670 671 # NOTE: This list could be more informative, but it is envisaged that 672 # NOTE: the requests would be visited directly anyway. 673 674 requests = self._get_requests() 675 676 self.page.div(id="pending-requests") 677 678 if requests: 679 self.page.p("Pending requests:") 680 681 self.page.ul() 682 683 for request in requests: 684 obj = self._get_object(request) 685 if obj: 686 self.page.li() 687 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 688 self.page.li.close() 689 690 self.page.ul.close() 691 692 else: 693 self.page.p("There are no pending requests.") 694 695 self.page.div.close() 696 697 def show_participants_on_page(self): 698 699 "Show participants for scheduling purposes." 700 701 args = self.env.get_args() 702 participants = args.get("participants", []) 703 704 try: 705 for name, value in args.items(): 706 if name.startswith("remove-participant-"): 707 i = int(name[len("remove-participant-"):]) 708 del participants[i] 709 break 710 except ValueError: 711 pass 712 713 # Trim empty participants. 714 715 while participants and not participants[-1].strip(): 716 participants.pop() 717 718 # Show any specified participants together with controls to remove and 719 # add participants. 720 721 self.page.div(id="participants") 722 723 self.page.p("Participants for scheduling:") 724 725 for i, participant in enumerate(participants): 726 self.page.p() 727 self.page.input(name="participants", type="text", value=participant) 728 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 729 self.page.p.close() 730 731 self.page.p() 732 self.page.input(name="participants", type="text") 733 self.page.input(name="add-participant", type="submit", value="Add") 734 self.page.p.close() 735 736 self.page.div.close() 737 738 return participants 739 740 # Full page output methods. 741 742 def show_object(self, path_info): 743 744 "Show an object request using the given 'path_info' for the current user." 745 746 uid = self._get_uid(path_info) 747 obj = self._get_object(uid) 748 749 if not obj: 750 return False 751 752 is_request = uid in self._get_requests() 753 handled = self.handle_request(uid, obj, is_request) 754 755 if handled: 756 return True 757 758 self.new_page(title="Event") 759 self.show_object_on_page(uid, obj, is_request and not handled) 760 761 return True 762 763 def show_calendar(self): 764 765 "Show the calendar for the current user." 766 767 handled = self.handle_newevent() 768 769 self.new_page(title="Calendar") 770 page = self.page 771 772 # Form controls are used in various places on the calendar page. 773 774 page.form(method="POST") 775 776 self.show_requests_on_page() 777 participants = self.show_participants_on_page() 778 779 # Show a button for scheduling a new event. 780 781 page.p(class_="controls") 782 page.input(name="newevent", type="submit", value="New event", id="newevent") 783 page.p.close() 784 785 # Show controls for hiding empty and busy slots. 786 # The positioning of the control, paragraph and table are important here. 787 788 page.input(name="hideslots", type="checkbox", value="hide", id="hideslots") 789 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy") 790 791 page.p(class_="controls") 792 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 793 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 794 page.label("Hide unused time periods", for_="hideslots", class_="hideslots enable") 795 page.label("Show unused time periods", for_="hideslots", class_="hideslots disable") 796 page.p.close() 797 798 freebusy = self.store.get_freebusy(self.user) 799 800 if not freebusy: 801 page.p("No events scheduled.") 802 return 803 804 # Obtain the user's timezone. 805 806 prefs = self.get_preferences() 807 tzid = prefs.get("TZID", "UTC") 808 809 # Day view: start at the earliest known day and produce days until the 810 # latest known day, perhaps with expandable sections of empty days. 811 812 # Month view: start at the earliest known month and produce months until 813 # the latest known month, perhaps with expandable sections of empty 814 # months. 815 816 # Details of users to invite to new events could be superimposed on the 817 # calendar. 818 819 # Requests are listed and linked to their tentative positions in the 820 # calendar. Other participants are also shown. 821 822 request_summary = self._get_request_summary() 823 824 period_groups = [request_summary, freebusy] 825 period_group_types = ["request", "freebusy"] 826 period_group_sources = ["Pending requests", "Your schedule"] 827 828 for i, participant in enumerate(participants): 829 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 830 period_group_types.append("freebusy-part%d" % i) 831 period_group_sources.append(participant) 832 833 groups = [] 834 group_columns = [] 835 group_types = period_group_types 836 group_sources = period_group_sources 837 all_points = set() 838 839 # Obtain time point information for each group of periods. 840 841 for periods in period_groups: 842 periods = convert_periods(periods, tzid) 843 844 # Get the time scale with start and end points. 845 846 scale = get_scale(periods) 847 848 # Get the time slots for the periods. 849 850 slots = get_slots(scale) 851 852 # Add start of day time points for multi-day periods. 853 854 add_day_start_points(slots) 855 856 # Record the slots and all time points employed. 857 858 groups.append(slots) 859 all_points.update([point for point, active in slots]) 860 861 # Partition the groups into days. 862 863 days = {} 864 partitioned_groups = [] 865 partitioned_group_types = [] 866 partitioned_group_sources = [] 867 868 for slots, group_type, group_source in zip(groups, group_types, group_sources): 869 870 # Propagate time points to all groups of time slots. 871 872 add_slots(slots, all_points) 873 874 # Count the number of columns employed by the group. 875 876 columns = 0 877 878 # Partition the time slots by day. 879 880 partitioned = {} 881 882 for day, day_slots in partition_by_day(slots).items(): 883 intervals = [] 884 last = None 885 886 for point, active in day_slots: 887 columns = max(columns, len(active)) 888 if last: 889 intervals.append((last, point)) 890 last = point 891 892 if last: 893 intervals.append((last, None)) 894 895 if not days.has_key(day): 896 days[day] = set() 897 898 # Convert each partition to a mapping from points to active 899 # periods. 900 901 partitioned[day] = dict(day_slots) 902 903 # Record the divisions or intervals within each day. 904 905 days[day].update(intervals) 906 907 if group_type != "request" or columns: 908 group_columns.append(columns) 909 partitioned_groups.append(partitioned) 910 partitioned_group_types.append(group_type) 911 partitioned_group_sources.append(group_source) 912 913 self.show_calendar_day_controls(days) 914 915 page.table(cellspacing=5, cellpadding=5, class_="calendar") 916 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 917 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 918 page.table.close() 919 920 # End the form region. 921 922 page.form.close() 923 924 def show_calendar_day_controls(self, days): 925 926 "Show controls for the given 'days' in the calendar." 927 928 page = self.page 929 slots = self.env.get_args().get("slot", []) 930 931 for day in days: 932 value, identifier = self._day_value_and_identifier(day) 933 self._slot_selector(value, identifier, slots) 934 935 # Generate a dynamic stylesheet to allow day selections to colour 936 # specific days. 937 # NOTE: The style details need to be coordinated with the static 938 # NOTE: stylesheet. 939 940 page.style(type="text/css") 941 942 for day in days: 943 daystr = format_datetime(day) 944 page.add("""\ 945 input.newevent#day-%s-:checked ~ table label.day.day-%s, 946 input.newevent#day-%s-:checked ~ table label.timepoint.day-%s { 947 background-color: #5f4; 948 text-decoration: underline; 949 } 950 """ % (daystr, daystr, daystr, daystr)) 951 952 page.style.close() 953 954 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 955 956 """ 957 Show headings for the participants and other scheduling contributors, 958 defined by 'group_types', 'group_sources' and 'group_columns'. 959 """ 960 961 page = self.page 962 963 page.colgroup(span=1, id="columns-timeslot") 964 965 for group_type, columns in zip(group_types, group_columns): 966 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 967 968 page.thead() 969 page.tr() 970 page.th("", class_="emptyheading") 971 972 for group_type, source, columns in zip(group_types, group_sources, group_columns): 973 page.th(source, 974 class_=(group_type == "request" and "requestheading" or "participantheading"), 975 colspan=max(columns, 1)) 976 977 page.tr.close() 978 page.thead.close() 979 980 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 981 982 """ 983 Show calendar days, defined by a collection of 'days', the contributing 984 period information as 'partitioned_groups' (partitioned by day), the 985 'partitioned_group_types' indicating the kind of contribution involved, 986 and the 'group_columns' defining the number of columns in each group. 987 """ 988 989 page = self.page 990 991 # Determine the number of columns required. Where participants provide 992 # no columns for events, one still needs to be provided for the 993 # participant itself. 994 995 all_columns = sum([max(columns, 1) for columns in group_columns]) 996 997 # Determine the days providing time slots. 998 999 all_days = days.items() 1000 all_days.sort() 1001 1002 # Produce a heading and time points for each day. 1003 1004 for day, intervals in all_days: 1005 page.thead() 1006 page.tr() 1007 page.th(class_="dayheading container", colspan=all_columns+1) 1008 self._day_heading(day) 1009 page.th.close() 1010 page.tr.close() 1011 page.thead.close() 1012 1013 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1014 1015 page.tbody() 1016 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1017 page.tbody.close() 1018 1019 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1020 1021 """ 1022 Show the time 'intervals' along with period information from the given 1023 'groups', having the indicated 'group_types', each with the number of 1024 columns given by 'group_columns'. 1025 """ 1026 1027 page = self.page 1028 1029 # Produce a row for each interval. 1030 1031 intervals = list(intervals) 1032 intervals.sort() 1033 1034 for point, endpoint in intervals: 1035 continuation = point == get_start_of_day(point) 1036 1037 # Some rows contain no period details and are marked as such. 1038 1039 have_active = reduce(lambda x, y: x or y, [slots.get(point) for slots in groups], None) 1040 1041 css = " ".join( 1042 ["slot"] + 1043 (have_active and ["busy"] or ["empty"]) + 1044 (continuation and ["daystart"] or []) 1045 ) 1046 1047 page.tr(class_=css) 1048 page.th(class_="timeslot") 1049 self._time_point(point, endpoint) 1050 page.th.close() 1051 1052 # Obtain slots for the time point from each group. 1053 1054 for columns, slots, group_type in zip(group_columns, groups, group_types): 1055 active = slots and slots.get(point) 1056 1057 # Where no periods exist for the given time interval, generate 1058 # an empty cell. Where a participant provides no periods at all, 1059 # the colspan is adjusted to be 1, not 0. 1060 1061 if not active: 1062 page.td(class_="empty container", colspan=max(columns, 1)) 1063 self._empty_slot(point, endpoint) 1064 page.td.close() 1065 continue 1066 1067 slots = slots.items() 1068 slots.sort() 1069 spans = get_spans(slots) 1070 1071 # Show a column for each active period. 1072 1073 for t in active: 1074 if t and len(t) >= 2: 1075 start, end, uid, key = get_freebusy_details(t) 1076 span = spans[key] 1077 1078 # Produce a table cell only at the start of the period 1079 # or when continued at the start of a day. 1080 1081 if point == start or continuation: 1082 1083 has_continued = continuation and point != start 1084 will_continue = not ends_on_same_day(point, end) 1085 css = " ".join( 1086 ["event"] + 1087 (has_continued and ["continued"] or []) + 1088 (will_continue and ["continues"] or []) 1089 ) 1090 1091 # Only anchor the first cell of events. 1092 1093 if point == start: 1094 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1095 else: 1096 page.td(class_=css, rowspan=span) 1097 1098 obj = self._get_object(uid) 1099 1100 if not obj: 1101 page.span("") 1102 else: 1103 summary = obj.get_value("SUMMARY") 1104 1105 # Only link to events if they are not being 1106 # updated by requests. 1107 1108 if uid in self._get_requests() and group_type != "request": 1109 page.span(summary) 1110 else: 1111 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1112 page.a(summary, href=href) 1113 1114 page.td.close() 1115 else: 1116 page.td(class_="empty container") 1117 self._empty_slot(point, endpoint) 1118 page.td.close() 1119 1120 # Pad with empty columns. 1121 1122 i = columns - len(active) 1123 while i > 0: 1124 i -= 1 1125 page.td(class_="empty container") 1126 self._empty_slot(point, endpoint) 1127 page.td.close() 1128 1129 page.tr.close() 1130 1131 def _day_heading(self, day): 1132 1133 """ 1134 Generate a heading for 'day' of the following form: 1135 1136 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1137 """ 1138 1139 page = self.page 1140 daystr = format_datetime(day) 1141 value, identifier = self._day_value_and_identifier(day) 1142 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1143 1144 def _time_point(self, point, endpoint): 1145 1146 """ 1147 Generate headings for the 'point' to 'endpoint' period of the following 1148 form: 1149 1150 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1151 <span class="endpoint">10:00:00 CET</span> 1152 """ 1153 1154 page = self.page 1155 daystr = format_datetime(point.date()) 1156 value, identifier = self._slot_value_and_identifier(point, endpoint) 1157 slots = self.env.get_args().get("slot", []) 1158 self._slot_selector(value, identifier, slots) 1159 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1160 page.span(self.format_time(endpoint or get_end_of_day(point), "long"), class_="endpoint") 1161 1162 def _slot_selector(self, value, identifier, slots): 1163 page = self.page 1164 if value in slots: 1165 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent", checked="checked") 1166 else: 1167 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent") 1168 1169 def _empty_slot(self, point, endpoint): 1170 page = self.page 1171 value, identifier = self._slot_value_and_identifier(point, endpoint) 1172 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1173 1174 def _day_value_and_identifier(self, day): 1175 value = "%s-" % format_datetime(day) 1176 identifier = "day-%s" % value 1177 return value, identifier 1178 1179 def _slot_value_and_identifier(self, point, endpoint): 1180 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1181 identifier = "slot-%s" % value 1182 return value, identifier 1183 1184 def select_action(self): 1185 1186 "Select the desired action and show the result." 1187 1188 path_info = self.env.get_path_info().strip("/") 1189 1190 if not path_info: 1191 self.show_calendar() 1192 elif self.show_object(path_info): 1193 pass 1194 else: 1195 self.no_page() 1196 1197 def __call__(self): 1198 1199 "Interpret a request and show an appropriate response." 1200 1201 if not self.user: 1202 self.no_user() 1203 else: 1204 self.select_action() 1205 1206 # Write the headers and actual content. 1207 1208 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1209 print >>self.out 1210 self.out.write(unicode(self.page).encode(self.encoding)) 1211 1212 if __name__ == "__main__": 1213 Manager()() 1214 1215 # vim: tabstop=4 expandtab shiftwidth=4