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