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