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