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