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