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, Object, to_part, \ 35 uri_dict, uri_item, uri_items, uri_values 36 from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \ 37 get_datetime_item, get_default_timezone, \ 38 get_end_of_day, get_start_of_day, get_start_of_next_day, \ 39 get_timestamp, ends_on_same_day, to_timezone 40 from imiptools.mail import Messenger 41 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ 42 convert_periods, get_freebusy_details, \ 43 get_scale, have_conflict, get_slots, get_spans, \ 44 partition_by_day, remove_from_freebusy, update_freebusy, \ 45 _update_freebusy 46 from imiptools.profile import Preferences 47 import imip_store 48 import markup 49 50 getenv = os.environ.get 51 setenv = os.environ.__setitem__ 52 53 class CGIEnvironment: 54 55 "A CGI-compatible environment." 56 57 def __init__(self, charset=None): 58 self.charset = charset 59 self.args = None 60 self.method = None 61 self.path = None 62 self.path_info = None 63 self.user = None 64 65 def get_args(self): 66 if self.args is None: 67 if self.get_method() != "POST": 68 setenv("QUERY_STRING", "") 69 args = cgi.parse(keep_blank_values=True) 70 71 if not self.charset: 72 self.args = args 73 else: 74 self.args = {} 75 for key, values in args.items(): 76 self.args[key] = [unicode(value, self.charset) for value in values] 77 78 return self.args 79 80 def get_method(self): 81 if self.method is None: 82 self.method = getenv("REQUEST_METHOD") or "GET" 83 return self.method 84 85 def get_path(self): 86 if self.path is None: 87 self.path = getenv("SCRIPT_NAME") or "" 88 return self.path 89 90 def get_path_info(self): 91 if self.path_info is None: 92 self.path_info = getenv("PATH_INFO") or "" 93 return self.path_info 94 95 def get_user(self): 96 if self.user is None: 97 self.user = getenv("REMOTE_USER") or "" 98 return self.user 99 100 def get_output(self): 101 return sys.stdout 102 103 def get_url(self): 104 path = self.get_path() 105 path_info = self.get_path_info() 106 return "%s%s" % (path.rstrip("/"), path_info) 107 108 def new_url(self, path_info): 109 path = self.get_path() 110 return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/")) 111 112 class Common: 113 114 "Common handler and manager methods." 115 116 def __init__(self, user): 117 self.user = user 118 self.preferences = None 119 120 def get_preferences(self): 121 if not self.preferences: 122 self.preferences = Preferences(self.user) 123 return self.preferences 124 125 def get_tzid(self): 126 prefs = self.get_preferences() 127 return prefs.get("TZID") or get_default_timezone() 128 129 class ManagerHandler(Handler, Common): 130 131 """ 132 A content handler for use by the manager, as opposed to operating within the 133 mail processing pipeline. 134 """ 135 136 def __init__(self, obj, user, messenger): 137 Handler.__init__(self, messenger=messenger) 138 Common.__init__(self, user) 139 140 self.set_object(obj) 141 142 # Communication methods. 143 144 def send_message(self, method, sender, for_organiser): 145 146 """ 147 Create a full calendar object employing the given 'method', and send it 148 to the appropriate recipients, also sending a copy to the 'sender'. The 149 'for_organiser' value indicates whether the organiser is sending this 150 message. 151 """ 152 153 parts = [self.obj.to_part(method)] 154 155 # As organiser, send an invitation to attendees, excluding oneself if 156 # also attending. The updated event will be saved by the outgoing 157 # handler. 158 159 organiser = uri_values(self.obj.get_value("ORGANIZER")) 160 attendees = uri_values(self.obj.get_values("ATTENDEE")) 161 162 if for_organiser: 163 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 164 else: 165 recipients = [get_address(organiser)] 166 167 # Bundle free/busy information if appropriate. 168 169 preferences = Preferences(self.user) 170 171 if preferences.get("freebusy_sharing") == "share" and \ 172 preferences.get("freebusy_bundling") == "always": 173 174 # Invent a unique identifier. 175 176 utcnow = get_timestamp() 177 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 178 179 freebusy = self.store.get_freebusy(self.user) 180 181 # Replace the non-updated free/busy details for this event with 182 # newer details (since the outgoing handler updates this user's 183 # free/busy details). 184 185 tzid = self.get_tzid() 186 187 _update_freebusy(freebusy, self.obj.get_periods_for_freebusy(tzid), 188 self.obj.get_value("TRANSP") or "OPAQUE", self.obj.get_value("UID")) 189 190 user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \ 191 {"SENT-BY" : get_uri(self.messenger.sender)} or {} 192 193 parts.append(to_part("PUBLISH", [ 194 make_freebusy(freebusy, uid, self.user, user_attr) 195 ])) 196 197 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 198 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 199 200 # Action methods. 201 202 def process_received_request(self, update=False): 203 204 """ 205 Process the current request for the given 'user'. Return whether any 206 action was taken. 207 208 If 'update' is given, the sequence number will be incremented in order 209 to override any previous response. 210 """ 211 212 # Reply only on behalf of this user. 213 214 for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")): 215 216 if attendee == self.user: 217 if attendee_attr.has_key("RSVP"): 218 del attendee_attr["RSVP"] 219 if self.messenger and self.messenger.sender != get_address(attendee): 220 attendee_attr["SENT-BY"] = get_uri(self.messenger.sender) 221 self.obj["ATTENDEE"] = [(attendee, attendee_attr)] 222 223 self.update_dtstamp() 224 self.set_sequence(update) 225 226 self.send_message("REPLY", get_address(attendee), for_organiser=False) 227 228 return True 229 230 return False 231 232 def process_created_request(self, method, update=False, removed=None): 233 234 """ 235 Process the current request for the given 'user', sending a created 236 request of the given 'method' to attendees. Return whether any action 237 was taken. 238 239 If 'update' is given, the sequence number will be incremented in order 240 to override any previous message. 241 242 If 'removed' is specified, a list of participants to be removed is 243 provided. 244 """ 245 246 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 247 248 if self.messenger and self.messenger.sender != get_address(organiser): 249 organiser_attr["SENT-BY"] = get_uri(self.messenger.sender) 250 251 if removed: 252 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 253 self.obj["ATTENDEE"] = [(attendee, attendee_attr) 254 for (attendee, attendee_attr) in attendee_map.items() 255 if attendee not in removed] 256 257 self.update_dtstamp() 258 self.set_sequence(update) 259 260 self.send_message(method, get_address(organiser), for_organiser=True) 261 262 return True 263 264 class Manager(Common): 265 266 "A simple manager application." 267 268 def __init__(self, messenger=None): 269 self.messenger = messenger or Messenger() 270 self.encoding = "utf-8" 271 self.env = CGIEnvironment(self.encoding) 272 273 user = self.env.get_user() 274 Common.__init__(self, user and get_uri(user) or None) 275 276 self.locale = None 277 self.requests = None 278 279 self.out = self.env.get_output() 280 self.page = markup.page() 281 282 self.store = imip_store.FileStore() 283 self.objects = {} 284 285 try: 286 self.publisher = imip_store.FilePublisher() 287 except OSError: 288 self.publisher = None 289 290 def _get_uid(self, path_info): 291 return path_info.lstrip("/").split("/", 1)[0] 292 293 def _get_object(self, uid): 294 if self.objects.has_key(uid): 295 return self.objects[uid] 296 297 fragment = uid and self.store.get_event(self.user, uid) or None 298 obj = self.objects[uid] = fragment and Object(fragment) 299 return obj 300 301 def _get_requests(self): 302 if self.requests is None: 303 self.requests = self.store.get_requests(self.user) 304 return self.requests 305 306 def _get_request_summary(self): 307 summary = [] 308 for uid in self._get_requests(): 309 obj = self._get_object(uid) 310 if obj: 311 summary.append(( 312 obj.get_value("DTSTART"), 313 obj.get_value("DTEND"), 314 uid 315 )) 316 return summary 317 318 # Preference methods. 319 320 def get_user_locale(self): 321 if not self.locale: 322 self.locale = self.get_preferences().get("LANG", "C") 323 return self.locale 324 325 # Prettyprinting of dates and times. 326 327 def format_date(self, dt, format): 328 return self._format_datetime(babel.dates.format_date, dt, format) 329 330 def format_time(self, dt, format): 331 return self._format_datetime(babel.dates.format_time, dt, format) 332 333 def format_datetime(self, dt, format): 334 return self._format_datetime( 335 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 336 dt, format) 337 338 def _format_datetime(self, fn, dt, format): 339 return fn(dt, format=format, locale=self.get_user_locale()) 340 341 # Data management methods. 342 343 def remove_request(self, uid): 344 return self.store.dequeue_request(self.user, uid) 345 346 def remove_event(self, uid): 347 return self.store.remove_event(self.user, uid) 348 349 def update_freebusy(self, uid, obj): 350 tzid = self.get_tzid() 351 freebusy = self.store.get_freebusy(self.user) 352 update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid), 353 obj.get_value("TRANSP"), uid, self.store) 354 355 def remove_from_freebusy(self, uid): 356 freebusy = self.store.get_freebusy(self.user) 357 remove_from_freebusy(freebusy, self.user, uid, self.store) 358 359 # Presentation methods. 360 361 def new_page(self, title): 362 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 363 364 def status(self, code, message): 365 self.header("Status", "%s %s" % (code, message)) 366 367 def header(self, header, value): 368 print >>self.out, "%s: %s" % (header, value) 369 370 def no_user(self): 371 self.status(403, "Forbidden") 372 self.new_page(title="Forbidden") 373 self.page.p("You are not logged in and thus cannot access scheduling requests.") 374 375 def no_page(self): 376 self.status(404, "Not Found") 377 self.new_page(title="Not Found") 378 self.page.p("No page is provided at the given address.") 379 380 def redirect(self, url): 381 self.status(302, "Redirect") 382 self.header("Location", url) 383 self.new_page(title="Redirect") 384 self.page.p("Redirecting to: %s" % url) 385 386 # Request logic methods. 387 388 def handle_newevent(self): 389 390 """ 391 Handle any new event operation, creating a new event and redirecting to 392 the event page for further activity. 393 """ 394 395 # Handle a submitted form. 396 397 args = self.env.get_args() 398 399 if not args.has_key("newevent"): 400 return 401 402 # Create a new event using the available information. 403 404 slots = args.get("slot", []) 405 participants = args.get("participants", []) 406 407 if not slots: 408 return 409 410 # Obtain the user's timezone. 411 412 tzid = self.get_tzid() 413 414 # Coalesce the selected slots. 415 416 slots.sort() 417 coalesced = [] 418 last = None 419 420 for slot in slots: 421 start, end = slot.split("-") 422 start = get_datetime(start, {"TZID" : tzid}) 423 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 424 425 if last: 426 last_start, last_end = last 427 428 # Merge adjacent dates and datetimes. 429 430 if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 431 last = last_start, end 432 continue 433 434 # Handle datetimes within dates. 435 # Datetime periods are within single days and are therefore 436 # discarded. 437 438 elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 439 continue 440 441 # Add separate dates and datetimes. 442 443 else: 444 coalesced.append(last) 445 446 last = start, end 447 448 if last: 449 coalesced.append(last) 450 451 # Invent a unique identifier. 452 453 utcnow = get_timestamp() 454 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 455 456 # Define a single occurrence if only one coalesced slot exists. 457 # Otherwise, many occurrences are defined. 458 459 for i, (start, end) in enumerate(coalesced): 460 this_uid = "%s-%s" % (uid, i) 461 462 start_value, start_attr = get_datetime_item(start, tzid) 463 end_value, end_attr = get_datetime_item(end, tzid) 464 465 # Create a calendar object and store it as a request. 466 467 record = [] 468 rwrite = record.append 469 470 rwrite(("UID", {}, this_uid)) 471 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 472 rwrite(("DTSTAMP", {}, utcnow)) 473 rwrite(("DTSTART", start_attr, start_value)) 474 rwrite(("DTEND", end_attr, end_value)) 475 rwrite(("ORGANIZER", {}, self.user)) 476 477 for participant in participants: 478 if not participant: 479 continue 480 participant = get_uri(participant) 481 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) 482 483 obj = ("VEVENT", {}, record) 484 485 self.store.set_event(self.user, this_uid, obj) 486 self.store.queue_request(self.user, this_uid) 487 488 # Redirect to the object (or the first of the objects), where instead of 489 # attendee controls, there will be organiser controls. 490 491 self.redirect(self.env.new_url("%s-0" % uid)) 492 493 def handle_request(self, uid, obj): 494 495 """ 496 Handle actions involving the given 'uid' and 'obj' object, returning an 497 error if one occurred, or None if the request was successfully handled. 498 """ 499 500 # Handle a submitted form. 501 502 args = self.env.get_args() 503 504 # Get the possible actions. 505 506 reply = args.has_key("reply") 507 discard = args.has_key("discard") 508 invite = args.has_key("invite") 509 cancel = args.has_key("cancel") 510 save = args.has_key("save") 511 512 have_action = reply or discard or invite or cancel or save 513 514 if not have_action: 515 return ["action"] 516 517 # Update the object. 518 519 if args.has_key("summary"): 520 obj["SUMMARY"] = [(args["summary"][0], {})] 521 522 organisers = uri_dict(obj.get_value_map("ORGANIZER")) 523 attendees = uri_dict(obj.get_value_map("ATTENDEE")) 524 525 if args.has_key("partstat"): 526 for d in attendees, organisers: 527 if d.has_key(self.user): 528 d[self.user]["PARTSTAT"] = args["partstat"][0] 529 if d[self.user].has_key("RSVP"): 530 del d[self.user]["RSVP"] 531 532 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 533 534 # Obtain any participants to be removed. 535 536 removed = args.get("remove") 537 538 # Obtain the user's timezone and process datetime values. 539 540 update = False 541 542 if is_organiser: 543 dtend_enabled = args.get("dtend-control", [None])[0] == "enable" 544 dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable" 545 546 t = self.handle_date_controls("dtstart", dttimes_enabled) 547 if t: 548 dtstart, attr = t 549 update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update 550 else: 551 return ["dtstart"] 552 553 # Handle specified end datetimes. 554 555 if dtend_enabled: 556 t = self.handle_date_controls("dtend", dttimes_enabled) 557 if t: 558 dtend, attr = t 559 560 # Convert end dates to iCalendar "next day" dates. 561 562 if not isinstance(dtend, datetime): 563 dtend += timedelta(1) 564 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 565 else: 566 return ["dtend"] 567 568 # Otherwise, treat the end date as the start date. Datetimes are 569 # handled by making the event occupy the rest of the day. 570 571 else: 572 dtend = dtstart + timedelta(1) 573 if isinstance(dtstart, datetime): 574 dtend = get_start_of_day(dtend, attr["TZID"]) 575 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 576 577 if dtstart >= dtend: 578 return ["dtstart", "dtend"] 579 580 # Process any action. 581 582 handled = True 583 584 if reply or invite or cancel: 585 586 handler = ManagerHandler(obj, self.user, self.messenger) 587 588 # Process the object and remove it from the list of requests. 589 590 if reply and handler.process_received_request(update) or \ 591 is_organiser and (invite or cancel) and \ 592 handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed): 593 594 self.remove_request(uid) 595 596 # Save single user events. 597 598 elif save: 599 self.store.set_event(self.user, uid, obj.to_node()) 600 self.update_freebusy(uid, obj) 601 self.remove_request(uid) 602 603 # Remove the request and the object. 604 605 elif discard: 606 self.remove_from_freebusy(uid) 607 self.remove_event(uid) 608 self.remove_request(uid) 609 610 else: 611 handled = False 612 613 # Upon handling an action, redirect to the main page. 614 615 if handled: 616 self.redirect(self.env.get_path()) 617 618 return None 619 620 def handle_date_controls(self, name, with_time=True): 621 622 """ 623 Handle date control information for fields starting with 'name', 624 returning a (datetime, attr) tuple or None if the fields cannot be used 625 to construct a datetime object. 626 """ 627 628 args = self.env.get_args() 629 630 if args.has_key("%s-date" % name): 631 date = args["%s-date" % name][0] 632 633 if with_time: 634 hour = args.get("%s-hour" % name, [None])[0] 635 minute = args.get("%s-minute" % name, [None])[0] 636 second = args.get("%s-second" % name, [None])[0] 637 tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0] 638 639 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or "" 640 value = "%s%s" % (date, time) 641 attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"} 642 dt = get_datetime(value, attr) 643 else: 644 attr = {"VALUE" : "DATE"} 645 dt = get_datetime(date) 646 647 if dt: 648 return dt, attr 649 650 return None 651 652 def set_datetime_in_object(self, dt, tzid, property, obj): 653 654 """ 655 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 656 an update has occurred. 657 """ 658 659 if dt: 660 old_value = obj.get_value(property) 661 obj[property] = [get_datetime_item(dt, tzid)] 662 return format_datetime(dt) != old_value 663 664 return False 665 666 # Page fragment methods. 667 668 def show_request_controls(self, obj): 669 670 "Show form controls for a request concerning 'obj'." 671 672 page = self.page 673 674 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 675 676 attendees = uri_dict(obj.get_value_map("ATTENDEE")) 677 is_attendee = attendees.has_key(self.user) 678 attendee_attr = attendees.get(self.user) 679 680 is_request = obj.get_value("UID") in self._get_requests() 681 682 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 683 684 # Show appropriate options depending on the role of the user. 685 686 if is_attendee and not is_organiser: 687 page.p("An action is required for this request:") 688 689 page.p() 690 page.input(name="reply", type="submit", value="Reply") 691 page.add(" ") 692 page.input(name="discard", type="submit", value="Discard") 693 page.p.close() 694 695 if is_organiser: 696 if have_other_attendees: 697 page.p("As organiser, you can perform the following:") 698 699 page.p() 700 page.input(name="invite", type="submit", value="Invite") 701 page.add(" ") 702 if is_request: 703 page.input(name="discard", type="submit", value="Discard") 704 else: 705 page.input(name="cancel", type="submit", value="Cancel") 706 page.p.close() 707 else: 708 page.p() 709 page.input(name="save", type="submit", value="Save") 710 page.add(" ") 711 page.input(name="discard", type="submit", value="Discard") 712 page.p.close() 713 714 property_items = [ 715 ("SUMMARY", "Summary"), 716 ("DTSTART", "Start"), 717 ("DTEND", "End"), 718 ("ORGANIZER", "Organiser"), 719 ("ATTENDEE", "Attendee"), 720 ] 721 722 partstat_items = [ 723 ("NEEDS-ACTION", "Not confirmed"), 724 ("ACCEPTED", "Attending"), 725 ("TENTATIVE", "Tentatively attending"), 726 ("DECLINED", "Not attending"), 727 ("DELEGATED", "Delegated"), 728 ] 729 730 def show_object_on_page(self, uid, obj, error=None): 731 732 """ 733 Show the calendar object with the given 'uid' and representation 'obj' 734 on the current page. If 'error' is given, show a suitable message. 735 """ 736 737 page = self.page 738 page.form(method="POST") 739 740 # Obtain the user's timezone. 741 742 tzid = self.get_tzid() 743 744 # Provide controls to change the displayed object. 745 746 args = self.env.get_args() 747 dtend_control = args.get("dtend-control", [None])[0] 748 dttimes_control = args.get("dttimes-control", [None])[0] 749 with_time = dttimes_control == "enable" 750 751 t = self.handle_date_controls("dtstart", with_time) 752 if t: 753 dtstart, dtstart_attr = t 754 else: 755 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 756 757 if dtend_control == "enable": 758 t = self.handle_date_controls("dtend", with_time) 759 if t: 760 dtend, dtend_attr = t 761 else: 762 dtend, dtend_attr = None, {} 763 elif dtend_control == "disable": 764 dtend, dtend_attr = None, {} 765 else: 766 dtend, dtend_attr = obj.get_datetime_item("DTEND") 767 768 # Change end dates to refer to the actual dates, not the iCalendar 769 # "next day" dates. 770 771 if dtend and not isinstance(dtend, datetime): 772 dtend -= timedelta(1) 773 774 # Show the end datetime controls if already active or if an object needs 775 # them. 776 777 dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend 778 dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) 779 780 if dtend_enabled: 781 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 782 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 783 else: 784 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 785 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 786 787 if dttimes_enabled: 788 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") 789 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") 790 else: 791 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") 792 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") 793 794 # Provide a summary of the object. 795 796 page.table(class_="object", cellspacing=5, cellpadding=5) 797 page.thead() 798 page.tr() 799 page.th("Event", class_="mainheading", colspan=2) 800 page.tr.close() 801 page.thead.close() 802 page.tbody() 803 804 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 805 806 for name, label in self.property_items: 807 page.tr() 808 809 # Handle datetimes specially. 810 811 if name in ["DTSTART", "DTEND"]: 812 field = name.lower() 813 814 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or "")) 815 816 # Obtain the datetime. 817 818 if name == "DTSTART": 819 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 820 821 # Where no end datetime exists, use the start datetime as the 822 # basis of any potential datetime specified if dt-control is 823 # set. 824 825 else: 826 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 827 828 # Show controls for editing as organiser. 829 830 if is_organiser: 831 value = format_datetime(dt) 832 833 page.td(class_="objectvalue %s" % field) 834 if name == "DTEND": 835 page.div(class_="dt disabled") 836 page.label("Specify end date", for_="dtend-enable", class_="enable") 837 page.div.close() 838 839 page.div(class_="dt enabled") 840 self._show_date_controls(field, value, attr, tzid) 841 if name == "DTSTART": 842 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 843 page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") 844 elif name == "DTEND": 845 page.label("End on same day", for_="dtend-disable", class_="disable") 846 page.div.close() 847 848 page.td.close() 849 850 # Show a label as attendee. 851 852 else: 853 page.td(self.format_datetime(dt, "full")) 854 855 page.tr.close() 856 857 # Handle the summary specially. 858 859 elif name == "SUMMARY": 860 value = args.get("summary", [obj.get_value(name)])[0] 861 862 page.th(label, class_="objectheading") 863 page.td() 864 if is_organiser: 865 page.input(name="summary", type="text", value=value, size=80) 866 else: 867 page.add(value) 868 page.td.close() 869 page.tr.close() 870 871 # Handle potentially many values. 872 873 else: 874 items = obj.get_items(name) 875 if not items: 876 continue 877 878 page.th(label, class_="objectheading", rowspan=len(items)) 879 880 first = True 881 882 for i, (value, attr) in enumerate(items): 883 if not first: 884 page.tr() 885 else: 886 first = False 887 888 if name in ("ATTENDEE", "ORGANIZER"): 889 value = get_uri(value) 890 891 page.td(class_="objectattribute") 892 page.add(value) 893 page.add(" ") 894 895 partstat = attr.get("PARTSTAT") 896 if value == self.user and (not is_organiser or name == "ORGANIZER"): 897 self._show_menu("partstat", partstat, self.partstat_items) 898 else: 899 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 900 901 if is_organiser and name == "ATTENDEE": 902 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") 903 page.label("Remove", for_="remove-%d" % i, class_="remove") 904 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 905 906 else: 907 page.td(class_="objectattribute") 908 page.add(value) 909 910 page.td.close() 911 page.tr.close() 912 913 page.tbody.close() 914 page.table.close() 915 916 self.show_conflicting_events(uid, obj) 917 self.show_request_controls(obj) 918 919 page.form.close() 920 921 def show_conflicting_events(self, uid, obj): 922 923 """ 924 Show conflicting events for the object having the given 'uid' and 925 representation 'obj'. 926 """ 927 928 page = self.page 929 930 # Obtain the user's timezone. 931 932 tzid = self.get_tzid() 933 934 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 935 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 936 937 # Indicate whether there are conflicting events. 938 939 freebusy = self.store.get_freebusy(self.user) 940 941 if freebusy: 942 943 # Obtain any time zone details from the suggested event. 944 945 _dtstart, attr = obj.get_item("DTSTART") 946 tzid = attr.get("TZID", tzid) 947 948 # Show any conflicts. 949 950 conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid] 951 952 if conflicts: 953 page.p("This event conflicts with others:") 954 955 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 956 page.thead() 957 page.tr() 958 page.th("Event") 959 page.th("Start") 960 page.th("End") 961 page.tr.close() 962 page.thead.close() 963 page.tbody() 964 965 for t in conflicts: 966 start, end, found_uid = t[:3] 967 968 # Provide details of any conflicting event. 969 970 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 971 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 972 973 page.tr() 974 975 # Show the event summary for the conflicting event. 976 977 page.td() 978 979 found_obj = self._get_object(found_uid) 980 if found_obj: 981 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 982 else: 983 page.add("No details available") 984 985 page.td.close() 986 987 page.td(start) 988 page.td(end) 989 990 page.tr.close() 991 992 page.tbody.close() 993 page.table.close() 994 995 def show_requests_on_page(self): 996 997 "Show requests for the current user." 998 999 # NOTE: This list could be more informative, but it is envisaged that 1000 # NOTE: the requests would be visited directly anyway. 1001 1002 requests = self._get_requests() 1003 1004 self.page.div(id="pending-requests") 1005 1006 if requests: 1007 self.page.p("Pending requests:") 1008 1009 self.page.ul() 1010 1011 for request in requests: 1012 obj = self._get_object(request) 1013 if obj: 1014 self.page.li() 1015 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 1016 self.page.li.close() 1017 1018 self.page.ul.close() 1019 1020 else: 1021 self.page.p("There are no pending requests.") 1022 1023 self.page.div.close() 1024 1025 def show_participants_on_page(self): 1026 1027 "Show participants for scheduling purposes." 1028 1029 args = self.env.get_args() 1030 participants = args.get("participants", []) 1031 1032 try: 1033 for name, value in args.items(): 1034 if name.startswith("remove-participant-"): 1035 i = int(name[len("remove-participant-"):]) 1036 del participants[i] 1037 break 1038 except ValueError: 1039 pass 1040 1041 # Trim empty participants. 1042 1043 while participants and not participants[-1].strip(): 1044 participants.pop() 1045 1046 # Show any specified participants together with controls to remove and 1047 # add participants. 1048 1049 self.page.div(id="participants") 1050 1051 self.page.p("Participants for scheduling:") 1052 1053 for i, participant in enumerate(participants): 1054 self.page.p() 1055 self.page.input(name="participants", type="text", value=participant) 1056 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1057 self.page.p.close() 1058 1059 self.page.p() 1060 self.page.input(name="participants", type="text") 1061 self.page.input(name="add-participant", type="submit", value="Add") 1062 self.page.p.close() 1063 1064 self.page.div.close() 1065 1066 return participants 1067 1068 # Full page output methods. 1069 1070 def show_object(self, path_info): 1071 1072 "Show an object request using the given 'path_info' for the current user." 1073 1074 uid = self._get_uid(path_info) 1075 obj = self._get_object(uid) 1076 1077 if not obj: 1078 return False 1079 1080 error = self.handle_request(uid, obj) 1081 1082 if not error: 1083 return True 1084 1085 self.new_page(title="Event") 1086 self.show_object_on_page(uid, obj, error) 1087 1088 return True 1089 1090 def show_calendar(self): 1091 1092 "Show the calendar for the current user." 1093 1094 handled = self.handle_newevent() 1095 1096 self.new_page(title="Calendar") 1097 page = self.page 1098 1099 # Form controls are used in various places on the calendar page. 1100 1101 page.form(method="POST") 1102 1103 self.show_requests_on_page() 1104 participants = self.show_participants_on_page() 1105 1106 # Show a button for scheduling a new event. 1107 1108 page.p(class_="controls") 1109 page.input(name="newevent", type="submit", value="New event", id="newevent") 1110 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1111 page.p.close() 1112 1113 # Show controls for hiding empty days and busy slots. 1114 # The positioning of the control, paragraph and table are important here. 1115 1116 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1117 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1118 1119 page.p(class_="controls") 1120 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1121 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1122 page.label("Show empty days", for_="showdays", class_="showdays disable") 1123 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1124 page.p.close() 1125 1126 freebusy = self.store.get_freebusy(self.user) 1127 1128 if not freebusy: 1129 page.p("No events scheduled.") 1130 return 1131 1132 # Obtain the user's timezone. 1133 1134 tzid = self.get_tzid() 1135 1136 # Day view: start at the earliest known day and produce days until the 1137 # latest known day, perhaps with expandable sections of empty days. 1138 1139 # Month view: start at the earliest known month and produce months until 1140 # the latest known month, perhaps with expandable sections of empty 1141 # months. 1142 1143 # Details of users to invite to new events could be superimposed on the 1144 # calendar. 1145 1146 # Requests are listed and linked to their tentative positions in the 1147 # calendar. Other participants are also shown. 1148 1149 request_summary = self._get_request_summary() 1150 1151 period_groups = [request_summary, freebusy] 1152 period_group_types = ["request", "freebusy"] 1153 period_group_sources = ["Pending requests", "Your schedule"] 1154 1155 for i, participant in enumerate(participants): 1156 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1157 period_group_types.append("freebusy-part%d" % i) 1158 period_group_sources.append(participant) 1159 1160 groups = [] 1161 group_columns = [] 1162 group_types = period_group_types 1163 group_sources = period_group_sources 1164 all_points = set() 1165 1166 # Obtain time point information for each group of periods. 1167 1168 for periods in period_groups: 1169 periods = convert_periods(periods, tzid) 1170 1171 # Get the time scale with start and end points. 1172 1173 scale = get_scale(periods) 1174 1175 # Get the time slots for the periods. 1176 1177 slots = get_slots(scale) 1178 1179 # Add start of day time points for multi-day periods. 1180 1181 add_day_start_points(slots, tzid) 1182 1183 # Record the slots and all time points employed. 1184 1185 groups.append(slots) 1186 all_points.update([point for point, active in slots]) 1187 1188 # Partition the groups into days. 1189 1190 days = {} 1191 partitioned_groups = [] 1192 partitioned_group_types = [] 1193 partitioned_group_sources = [] 1194 1195 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1196 1197 # Propagate time points to all groups of time slots. 1198 1199 add_slots(slots, all_points) 1200 1201 # Count the number of columns employed by the group. 1202 1203 columns = 0 1204 1205 # Partition the time slots by day. 1206 1207 partitioned = {} 1208 1209 for day, day_slots in partition_by_day(slots).items(): 1210 intervals = [] 1211 last = None 1212 1213 for point, active in day_slots: 1214 columns = max(columns, len(active)) 1215 if last: 1216 intervals.append((last, point)) 1217 last = point 1218 1219 if last: 1220 intervals.append((last, None)) 1221 1222 if not days.has_key(day): 1223 days[day] = set() 1224 1225 # Convert each partition to a mapping from points to active 1226 # periods. 1227 1228 partitioned[day] = dict(day_slots) 1229 1230 # Record the divisions or intervals within each day. 1231 1232 days[day].update(intervals) 1233 1234 if group_type != "request" or columns: 1235 group_columns.append(columns) 1236 partitioned_groups.append(partitioned) 1237 partitioned_group_types.append(group_type) 1238 partitioned_group_sources.append(group_source) 1239 1240 # Add empty days. 1241 1242 add_empty_days(days, tzid) 1243 1244 # Show the controls permitting day selection. 1245 1246 self.show_calendar_day_controls(days) 1247 1248 # Show the calendar itself. 1249 1250 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1251 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1252 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1253 page.table.close() 1254 1255 # End the form region. 1256 1257 page.form.close() 1258 1259 # More page fragment methods. 1260 1261 def show_calendar_day_controls(self, days): 1262 1263 "Show controls for the given 'days' in the calendar." 1264 1265 page = self.page 1266 slots = self.env.get_args().get("slot", []) 1267 1268 for day in days: 1269 value, identifier = self._day_value_and_identifier(day) 1270 self._slot_selector(value, identifier, slots) 1271 1272 # Generate a dynamic stylesheet to allow day selections to colour 1273 # specific days. 1274 # NOTE: The style details need to be coordinated with the static 1275 # NOTE: stylesheet. 1276 1277 page.style(type="text/css") 1278 1279 for day in days: 1280 daystr = format_datetime(day) 1281 page.add("""\ 1282 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1283 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1284 background-color: #5f4; 1285 text-decoration: underline; 1286 } 1287 """ % (daystr, daystr, daystr, daystr)) 1288 1289 page.style.close() 1290 1291 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1292 1293 """ 1294 Show headings for the participants and other scheduling contributors, 1295 defined by 'group_types', 'group_sources' and 'group_columns'. 1296 """ 1297 1298 page = self.page 1299 1300 page.colgroup(span=1, id="columns-timeslot") 1301 1302 for group_type, columns in zip(group_types, group_columns): 1303 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1304 1305 page.thead() 1306 page.tr() 1307 page.th("", class_="emptyheading") 1308 1309 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1310 page.th(source, 1311 class_=(group_type == "request" and "requestheading" or "participantheading"), 1312 colspan=max(columns, 1)) 1313 1314 page.tr.close() 1315 page.thead.close() 1316 1317 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1318 1319 """ 1320 Show calendar days, defined by a collection of 'days', the contributing 1321 period information as 'partitioned_groups' (partitioned by day), the 1322 'partitioned_group_types' indicating the kind of contribution involved, 1323 and the 'group_columns' defining the number of columns in each group. 1324 """ 1325 1326 page = self.page 1327 1328 # Determine the number of columns required. Where participants provide 1329 # no columns for events, one still needs to be provided for the 1330 # participant itself. 1331 1332 all_columns = sum([max(columns, 1) for columns in group_columns]) 1333 1334 # Determine the days providing time slots. 1335 1336 all_days = days.items() 1337 all_days.sort() 1338 1339 # Produce a heading and time points for each day. 1340 1341 for day, intervals in all_days: 1342 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1343 is_empty = True 1344 1345 for slots in groups_for_day: 1346 if not slots: 1347 continue 1348 1349 for active in slots.values(): 1350 if active: 1351 is_empty = False 1352 break 1353 1354 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1355 page.tr() 1356 page.th(class_="dayheading container", colspan=all_columns+1) 1357 self._day_heading(day) 1358 page.th.close() 1359 page.tr.close() 1360 page.thead.close() 1361 1362 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1363 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1364 page.tbody.close() 1365 1366 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1367 1368 """ 1369 Show the time 'intervals' along with period information from the given 1370 'groups', having the indicated 'group_types', each with the number of 1371 columns given by 'group_columns'. 1372 """ 1373 1374 page = self.page 1375 1376 # Obtain the user's timezone. 1377 1378 tzid = self.get_tzid() 1379 1380 # Produce a row for each interval. 1381 1382 intervals = list(intervals) 1383 intervals.sort() 1384 1385 for point, endpoint in intervals: 1386 continuation = point == get_start_of_day(point, tzid) 1387 1388 # Some rows contain no period details and are marked as such. 1389 1390 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1391 1392 css = " ".join( 1393 ["slot"] + 1394 (have_active and ["busy"] or ["empty"]) + 1395 (continuation and ["daystart"] or []) 1396 ) 1397 1398 page.tr(class_=css) 1399 page.th(class_="timeslot") 1400 self._time_point(point, endpoint) 1401 page.th.close() 1402 1403 # Obtain slots for the time point from each group. 1404 1405 for columns, slots, group_type in zip(group_columns, groups, group_types): 1406 active = slots and slots.get(point) 1407 1408 # Where no periods exist for the given time interval, generate 1409 # an empty cell. Where a participant provides no periods at all, 1410 # the colspan is adjusted to be 1, not 0. 1411 1412 if not active: 1413 page.td(class_="empty container", colspan=max(columns, 1)) 1414 self._empty_slot(point, endpoint) 1415 page.td.close() 1416 continue 1417 1418 slots = slots.items() 1419 slots.sort() 1420 spans = get_spans(slots) 1421 1422 empty = 0 1423 1424 # Show a column for each active period. 1425 1426 for t in active: 1427 if t and len(t) >= 2: 1428 1429 # Flush empty slots preceding this one. 1430 1431 if empty: 1432 page.td(class_="empty container", colspan=empty) 1433 self._empty_slot(point, endpoint) 1434 page.td.close() 1435 empty = 0 1436 1437 start, end, uid, key = get_freebusy_details(t) 1438 span = spans[key] 1439 1440 # Produce a table cell only at the start of the period 1441 # or when continued at the start of a day. 1442 1443 if point == start or continuation: 1444 1445 obj = self._get_object(uid) 1446 1447 has_continued = continuation and point != start 1448 will_continue = not ends_on_same_day(point, end, tzid) 1449 is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user 1450 1451 css = " ".join( 1452 ["event"] + 1453 (has_continued and ["continued"] or []) + 1454 (will_continue and ["continues"] or []) + 1455 (is_organiser and ["organising"] or ["attending"]) 1456 ) 1457 1458 # Only anchor the first cell of events. 1459 1460 if point == start: 1461 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1462 else: 1463 page.td(class_=css, rowspan=span) 1464 1465 if not obj: 1466 page.span("(Participant is busy)") 1467 else: 1468 summary = obj.get_value("SUMMARY") 1469 1470 # Only link to events if they are not being 1471 # updated by requests. 1472 1473 if uid in self._get_requests() and group_type != "request": 1474 page.span(summary) 1475 else: 1476 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1477 page.a(summary, href=href) 1478 1479 page.td.close() 1480 else: 1481 empty += 1 1482 1483 # Pad with empty columns. 1484 1485 empty = columns - len(active) 1486 1487 if empty: 1488 page.td(class_="empty container", colspan=empty) 1489 self._empty_slot(point, endpoint) 1490 page.td.close() 1491 1492 page.tr.close() 1493 1494 def _day_heading(self, day): 1495 1496 """ 1497 Generate a heading for 'day' of the following form: 1498 1499 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1500 """ 1501 1502 page = self.page 1503 daystr = format_datetime(day) 1504 value, identifier = self._day_value_and_identifier(day) 1505 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1506 1507 def _time_point(self, point, endpoint): 1508 1509 """ 1510 Generate headings for the 'point' to 'endpoint' period of the following 1511 form: 1512 1513 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1514 <span class="endpoint">10:00:00 CET</span> 1515 """ 1516 1517 page = self.page 1518 tzid = self.get_tzid() 1519 daystr = format_datetime(point.date()) 1520 value, identifier = self._slot_value_and_identifier(point, endpoint) 1521 slots = self.env.get_args().get("slot", []) 1522 self._slot_selector(value, identifier, slots) 1523 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1524 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1525 1526 def _slot_selector(self, value, identifier, slots): 1527 reset = self.env.get_args().has_key("reset") 1528 page = self.page 1529 if not reset and value in slots: 1530 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1531 else: 1532 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1533 1534 def _empty_slot(self, point, endpoint): 1535 page = self.page 1536 value, identifier = self._slot_value_and_identifier(point, endpoint) 1537 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1538 1539 def _day_value_and_identifier(self, day): 1540 value = "%s-" % format_datetime(day) 1541 identifier = "day-%s" % value 1542 return value, identifier 1543 1544 def _slot_value_and_identifier(self, point, endpoint): 1545 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1546 identifier = "slot-%s" % value 1547 return value, identifier 1548 1549 def _show_menu(self, name, default, items): 1550 page = self.page 1551 values = self.env.get_args().get(name, [default]) 1552 page.select(name=name) 1553 for v, label in items: 1554 if v in values: 1555 page.option(label, value=v, selected="selected") 1556 else: 1557 page.option(label, value=v) 1558 page.select.close() 1559 1560 def _show_date_controls(self, name, default, attr, tzid): 1561 1562 """ 1563 Show date controls for a field with the given 'name' and 'default' value 1564 and 'attr', with the given 'tzid' being used if no other time regime 1565 information is provided. 1566 """ 1567 1568 page = self.page 1569 args = self.env.get_args() 1570 1571 event_tzid = attr.get("TZID", tzid) 1572 dt = get_datetime(default, attr) 1573 1574 # Show dates for up to one week around the current date. 1575 1576 base = get_date(dt) 1577 items = [] 1578 for i in range(-7, 8): 1579 d = base + timedelta(i) 1580 items.append((format_datetime(d), self.format_date(d, "full"))) 1581 1582 self._show_menu("%s-date" % name, format_datetime(base), items) 1583 1584 # Show time details. 1585 1586 dt_time = isinstance(dt, datetime) and dt or None 1587 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1588 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1589 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1590 1591 page.span(class_="time enabled") 1592 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1593 page.add(":") 1594 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1595 page.add(":") 1596 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1597 page.add(" ") 1598 self._show_menu("%s-tzid" % name, event_tzid, 1599 [(event_tzid, event_tzid)] + ( 1600 event_tzid != tzid and [(tzid, tzid)] or [] 1601 )) 1602 page.span.close() 1603 1604 # Incoming HTTP request direction. 1605 1606 def select_action(self): 1607 1608 "Select the desired action and show the result." 1609 1610 path_info = self.env.get_path_info().strip("/") 1611 1612 if not path_info: 1613 self.show_calendar() 1614 elif self.show_object(path_info): 1615 pass 1616 else: 1617 self.no_page() 1618 1619 def __call__(self): 1620 1621 "Interpret a request and show an appropriate response." 1622 1623 if not self.user: 1624 self.no_user() 1625 else: 1626 self.select_action() 1627 1628 # Write the headers and actual content. 1629 1630 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1631 print >>self.out 1632 self.out.write(unicode(self.page).encode(self.encoding)) 1633 1634 if __name__ == "__main__": 1635 Manager()() 1636 1637 # vim: tabstop=4 expandtab shiftwidth=4