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