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