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): 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 # Presentation methods. 316 317 def new_page(self, title): 318 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 319 320 def status(self, code, message): 321 self.header("Status", "%s %s" % (code, message)) 322 323 def header(self, header, value): 324 print >>self.out, "%s: %s" % (header, value) 325 326 def no_user(self): 327 self.status(403, "Forbidden") 328 self.new_page(title="Forbidden") 329 self.page.p("You are not logged in and thus cannot access scheduling requests.") 330 331 def no_page(self): 332 self.status(404, "Not Found") 333 self.new_page(title="Not Found") 334 self.page.p("No page is provided at the given address.") 335 336 def redirect(self, url): 337 self.status(302, "Redirect") 338 self.header("Location", url) 339 self.new_page(title="Redirect") 340 self.page.p("Redirecting to: %s" % url) 341 342 # Request logic and page fragment methods. 343 344 def handle_newevent(self): 345 346 """ 347 Handle any new event operation, creating a new event and redirecting to 348 the event page for further activity. 349 """ 350 351 # Handle a submitted form. 352 353 args = self.env.get_args() 354 355 if not args.has_key("newevent"): 356 return 357 358 # Create a new event using the available information. 359 360 slot = args.get("slot", [None])[0] 361 participants = args.get("participants", []) 362 363 if not slot: 364 return 365 366 start, end = slot.split("-") 367 368 # Obtain the user's timezone. 369 370 prefs = self.get_preferences() 371 tzid = prefs.get("TZID", "UTC") 372 373 # Invent a unique identifier. 374 375 utcnow = get_timestamp() 376 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 377 378 # Create a calendar object and store it as a request. 379 380 record = [] 381 rwrite = record.append 382 383 rwrite(("UID", {}, uid)) 384 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 385 rwrite(("DTSTAMP", {}, utcnow)) 386 rwrite(("DTSTART", {"VALUE" : "DATE-TIME", "TZID" : tzid}, start)) 387 rwrite(("DTEND", {"VALUE" : "DATE-TIME", "TZID" : tzid}, end or 388 format_datetime(get_end_of_day(get_datetime(start, {"TZID" : tzid}))) 389 )) 390 rwrite(("ORGANIZER", {}, self.user)) 391 392 for participant in participants: 393 if not participant: 394 continue 395 participant = get_uri(participant) 396 if participant != self.user: 397 rwrite(("ATTENDEE", {}, participant)) 398 399 obj = ("VEVENT", {}, record) 400 401 self.store.set_event(self.user, uid, obj) 402 self.store.queue_request(self.user, uid) 403 404 # Redirect to the object, where instead of attendee controls, 405 # there will be organiser controls. 406 407 self.redirect(self.env.new_url(uid)) 408 409 def handle_request(self, uid, obj, queued): 410 411 """ 412 Handle actions involving the given 'uid' and 'obj' object, where 413 'queued' indicates that the object has not yet been handled. 414 """ 415 416 # Handle a submitted form. 417 418 args = self.env.get_args() 419 handled = True 420 421 # Update the object. 422 423 if args.has_key("summary"): 424 obj["SUMMARY"] = [(args["summary"][0], {})] 425 426 # Process any action. 427 428 accept = args.has_key("accept") 429 decline = args.has_key("decline") 430 invite = args.has_key("invite") 431 update = not queued and args.has_key("update") 432 433 if accept or decline or invite: 434 435 handler = ManagerHandler(obj, self.user, self.messenger) 436 437 # Process the object and remove it from the list of requests. 438 439 if (accept or decline) and handler.process_received_request(accept, update) or \ 440 invite and handler.process_created_request(update): 441 442 self.remove_request(uid) 443 444 elif args.has_key("discard"): 445 446 # Remove the request from the list. 447 448 self.remove_request(uid) 449 450 else: 451 handled = False 452 453 # Upon handling an action, redirect to the main page. 454 455 if handled: 456 self.redirect(self.env.get_path()) 457 458 return handled 459 460 def show_request_controls(self, obj, needs_action): 461 462 """ 463 Show form controls for a request concerning 'obj', indicating whether 464 action is needed if 'needs_action' is specified as a true value. 465 """ 466 467 page = self.page 468 469 is_organiser = obj.get_value("ORGANIZER") == self.user 470 471 if not is_organiser: 472 attendees = obj.get_value_map("ATTENDEE") 473 attendee_attr = attendees.get(self.user) 474 475 if attendee_attr: 476 partstat = attendee_attr.get("PARTSTAT") 477 if partstat == "ACCEPTED": 478 page.p("This request has been accepted.") 479 elif partstat == "DECLINED": 480 page.p("This request has been declined.") 481 else: 482 page.p("This request has not yet been dealt with.") 483 484 if needs_action: 485 page.p("An action is required for this request:") 486 else: 487 page.p("This request can be updated as follows:") 488 489 page.p() 490 491 # Show appropriate options depending on the role of the user. 492 493 if is_organiser: 494 page.input(name="invite", type="submit", value="Invite") 495 else: 496 page.input(name="accept", type="submit", value="Accept") 497 page.add(" ") 498 page.input(name="decline", type="submit", value="Decline") 499 500 page.add(" ") 501 page.input(name="discard", type="submit", value="Discard") 502 503 # Updated objects need to have details updated upon sending. 504 505 if not needs_action: 506 page.input(name="update", type="hidden", value="true") 507 508 page.p.close() 509 510 object_labels = { 511 "SUMMARY" : "Summary", 512 "DTSTART" : "Start", 513 "DTEND" : "End", 514 "ORGANIZER" : "Organiser", 515 "ATTENDEE" : "Attendee", 516 } 517 518 def show_object_on_page(self, uid, obj, needs_action): 519 520 """ 521 Show the calendar object with the given 'uid' and representation 'obj' 522 on the current page. 523 """ 524 525 page = self.page 526 page.form(method="POST") 527 528 # Obtain the user's timezone. 529 530 prefs = self.get_preferences() 531 tzid = prefs.get("TZID", "UTC") 532 533 # Provide a summary of the object. 534 535 page.table(class_="object", cellspacing=5, cellpadding=5) 536 page.thead() 537 page.tr() 538 page.th("Event", class_="mainheading", colspan=2) 539 page.tr.close() 540 page.thead.close() 541 page.tbody() 542 543 for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]: 544 page.tr() 545 546 label = self.object_labels.get(name, name) 547 548 # Handle datetimes specially. 549 550 if name in ["DTSTART", "DTEND"]: 551 value, attr = obj.get_item(name) 552 tzid = attr.get("TZID", tzid) 553 value = ( 554 name == "DTSTART" and self.format_datetime or self.format_end_datetime 555 )(to_timezone(get_datetime(value), tzid), "full") 556 page.th(label, class_="objectheading") 557 page.td(value) 558 page.tr.close() 559 560 # Handle the summary specially. 561 562 elif name == "SUMMARY": 563 value = obj.get_value(name) 564 page.th(label, class_="objectheading") 565 page.td() 566 page.input(name="summary", type="text", value=value, size=80) 567 page.td.close() 568 page.tr.close() 569 570 # Handle potentially many values. 571 572 else: 573 items = obj.get_items(name) 574 if not items: 575 continue 576 577 page.th(label, class_="objectheading", rowspan=len(items)) 578 579 first = True 580 581 for value, attr in items: 582 if not first: 583 page.tr() 584 else: 585 first = False 586 587 page.td() 588 page.add(value) 589 590 if name == "ATTENDEE": 591 partstat = attr.get("PARTSTAT") 592 if partstat: 593 page.add(" (%s)" % partstat) 594 595 page.td.close() 596 page.tr.close() 597 598 page.tbody.close() 599 page.table.close() 600 601 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 602 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 603 604 # Indicate whether there are conflicting events. 605 606 freebusy = self.store.get_freebusy(self.user) 607 608 if freebusy: 609 610 # Obtain any time zone details from the suggested event. 611 612 _dtstart, attr = obj.get_item("DTSTART") 613 tzid = attr.get("TZID", tzid) 614 615 # Show any conflicts. 616 617 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 618 start, end, found_uid = t[:3] 619 620 # Provide details of any conflicting event. 621 622 if uid != found_uid: 623 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 624 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 625 page.p("Event conflicts with another from %s to %s: " % (start, end)) 626 627 # Show the event summary for the conflicting event. 628 629 found_obj = self._get_object(found_uid) 630 if found_obj: 631 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 632 633 self.show_request_controls(obj, needs_action) 634 page.form.close() 635 636 def show_requests_on_page(self): 637 638 "Show requests for the current user." 639 640 # NOTE: This list could be more informative, but it is envisaged that 641 # NOTE: the requests would be visited directly anyway. 642 643 requests = self._get_requests() 644 645 self.page.div(id="pending-requests") 646 647 if requests: 648 self.page.p("Pending requests:") 649 650 self.page.ul() 651 652 for request in requests: 653 obj = self._get_object(request) 654 if obj: 655 self.page.li() 656 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 657 self.page.li.close() 658 659 self.page.ul.close() 660 661 else: 662 self.page.p("There are no pending requests.") 663 664 self.page.div.close() 665 666 def show_participants_on_page(self): 667 668 "Show participants for scheduling purposes." 669 670 args = self.env.get_args() 671 participants = args.get("participants", []) 672 673 try: 674 for name, value in args.items(): 675 if name.startswith("remove-participant-"): 676 i = int(name[len("remove-participant-"):]) 677 del participants[i] 678 break 679 except ValueError: 680 pass 681 682 # Trim empty participants. 683 684 while participants and not participants[-1].strip(): 685 participants.pop() 686 687 # Show any specified participants together with controls to remove and 688 # add participants. 689 690 self.page.div(id="participants") 691 692 self.page.p("Participants for scheduling:") 693 694 for i, participant in enumerate(participants): 695 self.page.p() 696 self.page.input(name="participants", type="text", value=participant) 697 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 698 self.page.p.close() 699 700 self.page.p() 701 self.page.input(name="participants", type="text") 702 self.page.input(name="add-participant", type="submit", value="Add") 703 self.page.p.close() 704 705 self.page.div.close() 706 707 return participants 708 709 # Full page output methods. 710 711 def show_object(self, path_info): 712 713 "Show an object request using the given 'path_info' for the current user." 714 715 uid = self._get_uid(path_info) 716 obj = self._get_object(uid) 717 718 if not obj: 719 return False 720 721 is_request = uid in self._get_requests() 722 handled = self.handle_request(uid, obj, is_request) 723 724 if handled: 725 return True 726 727 self.new_page(title="Event") 728 self.show_object_on_page(uid, obj, is_request and not handled) 729 730 return True 731 732 def show_calendar(self): 733 734 "Show the calendar for the current user." 735 736 handled = self.handle_newevent() 737 738 self.new_page(title="Calendar") 739 page = self.page 740 741 # Form controls are used in various places on the calendar page. 742 743 page.form(method="POST") 744 745 self.show_requests_on_page() 746 participants = self.show_participants_on_page() 747 748 # Show a button for scheduling a new event. 749 750 page.p(class_="controls") 751 page.input(name="newevent", type="submit", value="New event", id="newevent") 752 page.p.close() 753 754 # Show controls for hiding empty and busy slots. 755 # The positioning of the control, paragraph and table are important here. 756 757 page.input(name="hideslots", type="checkbox", value="hide", id="hideslots") 758 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy") 759 760 page.p(class_="controls") 761 page.label("Hide busy time periods", for_="hidebusy", class_="enable") 762 page.label("Show busy time periods", for_="hidebusy", class_="disable") 763 page.label("Hide unused time periods", for_="hideslots", class_="enable") 764 page.label("Show unused time periods", for_="hideslots", class_="disable") 765 page.p.close() 766 767 freebusy = self.store.get_freebusy(self.user) 768 769 if not freebusy: 770 page.p("No events scheduled.") 771 return 772 773 # Obtain the user's timezone. 774 775 prefs = self.get_preferences() 776 tzid = prefs.get("TZID", "UTC") 777 778 # Day view: start at the earliest known day and produce days until the 779 # latest known day, perhaps with expandable sections of empty days. 780 781 # Month view: start at the earliest known month and produce months until 782 # the latest known month, perhaps with expandable sections of empty 783 # months. 784 785 # Details of users to invite to new events could be superimposed on the 786 # calendar. 787 788 # Requests are listed and linked to their tentative positions in the 789 # calendar. Other participants are also shown. 790 791 request_summary = self._get_request_summary() 792 793 period_groups = [request_summary, freebusy] 794 period_group_types = ["request", "freebusy"] 795 period_group_sources = ["Pending requests", "Your schedule"] 796 797 for i, participant in enumerate(participants): 798 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 799 period_group_types.append("freebusy-part%d" % i) 800 period_group_sources.append(participant) 801 802 groups = [] 803 group_columns = [] 804 group_types = period_group_types 805 group_sources = period_group_sources 806 all_points = set() 807 808 # Obtain time point information for each group of periods. 809 810 for periods in period_groups: 811 periods = convert_periods(periods, tzid) 812 813 # Get the time scale with start and end points. 814 815 scale = get_scale(periods) 816 817 # Get the time slots for the periods. 818 819 slots = get_slots(scale) 820 821 # Add start of day time points for multi-day periods. 822 823 add_day_start_points(slots) 824 825 # Record the slots and all time points employed. 826 827 groups.append(slots) 828 all_points.update([point for point, active in slots]) 829 830 # Partition the groups into days. 831 832 days = {} 833 partitioned_groups = [] 834 partitioned_group_types = [] 835 partitioned_group_sources = [] 836 837 for slots, group_type, group_source in zip(groups, group_types, group_sources): 838 839 # Propagate time points to all groups of time slots. 840 841 add_slots(slots, all_points) 842 843 # Count the number of columns employed by the group. 844 845 columns = 0 846 847 # Partition the time slots by day. 848 849 partitioned = {} 850 851 for day, day_slots in partition_by_day(slots).items(): 852 intervals = [] 853 last = None 854 855 for point, active in day_slots: 856 columns = max(columns, len(active)) 857 if last: 858 intervals.append((last, point)) 859 last = point 860 861 if last: 862 intervals.append((last, None)) 863 864 if not days.has_key(day): 865 days[day] = set() 866 867 # Convert each partition to a mapping from points to active 868 # periods. 869 870 partitioned[day] = dict(day_slots) 871 872 # Record the divisions or intervals within each day. 873 874 days[day].update(intervals) 875 876 if group_type != "request" or columns: 877 group_columns.append(columns) 878 partitioned_groups.append(partitioned) 879 partitioned_group_types.append(group_type) 880 partitioned_group_sources.append(group_source) 881 882 page.table(cellspacing=5, cellpadding=5, class_="calendar") 883 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 884 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 885 page.table.close() 886 887 # End the form region. 888 889 page.form.close() 890 891 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 892 893 """ 894 Show headings for the participants and other scheduling contributors, 895 defined by 'group_types', 'group_sources' and 'group_columns'. 896 """ 897 898 page = self.page 899 900 page.colgroup(span=1, id="columns-timeslot") 901 902 for group_type, columns in zip(group_types, group_columns): 903 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 904 905 page.thead() 906 page.tr() 907 page.th("", class_="emptyheading") 908 909 for group_type, source, columns in zip(group_types, group_sources, group_columns): 910 page.th(source, 911 class_=(group_type == "request" and "requestheading" or "participantheading"), 912 colspan=max(columns, 1)) 913 914 page.tr.close() 915 page.thead.close() 916 917 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 918 919 """ 920 Show calendar days, defined by a collection of 'days', the contributing 921 period information as 'partitioned_groups' (partitioned by day), the 922 'partitioned_group_types' indicating the kind of contribution involved, 923 and the 'group_columns' defining the number of columns in each group. 924 """ 925 926 page = self.page 927 928 # Determine the number of columns required. Where participants provide 929 # no columns for events, one still needs to be provided for the 930 # participant itself. 931 932 all_columns = sum([max(columns, 1) for columns in group_columns]) 933 934 # Determine the days providing time slots. 935 936 all_days = days.items() 937 all_days.sort() 938 939 # Produce a heading and time points for each day. 940 941 for day, intervals in all_days: 942 page.thead() 943 page.tr() 944 page.th(class_="dayheading", colspan=all_columns+1) 945 page.add(self.format_date(day, "full")) 946 page.th.close() 947 page.tr.close() 948 page.thead.close() 949 950 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 951 952 page.tbody() 953 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 954 page.tbody.close() 955 956 def show_calendar_points(self, intervals, groups, group_types, group_columns): 957 958 """ 959 Show the time 'intervals' along with period information from the given 960 'groups', having the indicated 'group_types', each with the number of 961 columns given by 'group_columns'. 962 """ 963 964 page = self.page 965 966 # Produce a row for each interval. 967 968 intervals = list(intervals) 969 intervals.sort() 970 971 for point, endpoint in intervals: 972 continuation = point == get_start_of_day(point) 973 974 # Some rows contain no period details and are marked as such. 975 976 have_active = reduce(lambda x, y: x or y, [slots.get(point) for slots in groups], None) 977 978 css = " ".join( 979 ["slot"] + 980 (have_active and ["busy"] or ["empty"]) + 981 (continuation and ["daystart"] or []) 982 ) 983 984 page.tr(class_=css) 985 page.th(class_="timeslot") 986 self._time_point(point, endpoint) 987 page.th.close() 988 989 # Obtain slots for the time point from each group. 990 991 for columns, slots, group_type in zip(group_columns, groups, group_types): 992 active = slots and slots.get(point) 993 994 # Where no periods exist for the given time interval, generate 995 # an empty cell. Where a participant provides no periods at all, 996 # the colspan is adjusted to be 1, not 0. 997 998 if not active: 999 page.td(class_="empty container", colspan=max(columns, 1)) 1000 self._empty_slot(point, endpoint) 1001 page.td.close() 1002 continue 1003 1004 slots = slots.items() 1005 slots.sort() 1006 spans = get_spans(slots) 1007 1008 # Show a column for each active period. 1009 1010 for t in active: 1011 if t and len(t) >= 2: 1012 start, end, uid, key = get_freebusy_details(t) 1013 span = spans[key] 1014 1015 # Produce a table cell only at the start of the period 1016 # or when continued at the start of a day. 1017 1018 if point == start or continuation: 1019 1020 has_continued = continuation and point != start 1021 will_continue = not ends_on_same_day(point, end) 1022 css = " ".join( 1023 ["event"] + 1024 (has_continued and ["continued"] or []) + 1025 (will_continue and ["continues"] or []) 1026 ) 1027 1028 # Only anchor the first cell of events. 1029 1030 if point == start: 1031 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1032 else: 1033 page.td(class_=css, rowspan=span) 1034 1035 obj = self._get_object(uid) 1036 1037 if not obj: 1038 page.span("") 1039 else: 1040 summary = obj.get_value("SUMMARY") 1041 1042 # Only link to events if they are not being 1043 # updated by requests. 1044 1045 if uid in self._get_requests() and group_type != "request": 1046 page.span(summary) 1047 else: 1048 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1049 page.a(summary, href=href) 1050 1051 page.td.close() 1052 else: 1053 page.td(class_="empty container") 1054 self._empty_slot(point, endpoint) 1055 page.td.close() 1056 1057 # Pad with empty columns. 1058 1059 i = columns - len(active) 1060 while i > 0: 1061 i -= 1 1062 page.td(class_="empty container") 1063 self._empty_slot(point, endpoint) 1064 page.td.close() 1065 1066 page.tr.close() 1067 1068 def _time_point(self, point, endpoint): 1069 page = self.page 1070 value, identifier = self._slot_value_and_identifier(point, endpoint) 1071 slot = self.env.get_args().get("slot", [None])[0] 1072 if slot == value: 1073 page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent", checked="checked") 1074 else: 1075 page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent") 1076 page.label(self.format_time(point, "long"), class_="timepoint", for_=identifier) 1077 1078 def _empty_slot(self, point, endpoint): 1079 page = self.page 1080 value, identifier = self._slot_value_and_identifier(point, endpoint) 1081 page.label("Make a new event in this period", class_="newevent popup", for_=identifier) 1082 1083 def _slot_value_and_identifier(self, point, endpoint): 1084 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1085 identifier = "slot-%s" % value 1086 return value, identifier 1087 1088 def select_action(self): 1089 1090 "Select the desired action and show the result." 1091 1092 path_info = self.env.get_path_info().strip("/") 1093 1094 if not path_info: 1095 self.show_calendar() 1096 elif self.show_object(path_info): 1097 pass 1098 else: 1099 self.no_page() 1100 1101 def __call__(self): 1102 1103 "Interpret a request and show an appropriate response." 1104 1105 if not self.user: 1106 self.no_user() 1107 else: 1108 self.select_action() 1109 1110 # Write the headers and actual content. 1111 1112 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1113 print >>self.out 1114 self.out.write(unicode(self.page).encode(self.encoding)) 1115 1116 if __name__ == "__main__": 1117 Manager()() 1118 1119 # vim: tabstop=4 expandtab shiftwidth=4