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