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