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