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