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