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