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