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