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