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_recurrences(obj) 994 self.show_conflicting_events(uid, obj) 995 self.show_request_controls(obj) 996 997 page.form.close() 998 999 def show_recurrences(self, obj): 1000 1001 "Show recurrences for the object having the given representation 'obj'." 1002 1003 page = self.page 1004 1005 # Obtain the user's timezone. 1006 1007 tzid = self.get_tzid() 1008 1009 window_size = 100 1010 1011 periods = obj.get_periods(self.get_tzid(), window_size) 1012 1013 if len(periods) == 1: 1014 return 1015 1016 page.p("This event occurs on the following occasions within the next %d days:" % window_size) 1017 1018 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1019 page.thead() 1020 page.tr() 1021 page.th("Start") 1022 page.th("End") 1023 page.tr.close() 1024 page.thead.close() 1025 page.tbody() 1026 1027 for start, end in periods: 1028 page.tr() 1029 page.td(self.format_datetime(start, "long")) 1030 page.td(self.format_datetime(end, "long")) 1031 page.tr.close() 1032 1033 page.tbody.close() 1034 page.table.close() 1035 1036 def show_conflicting_events(self, uid, obj): 1037 1038 """ 1039 Show conflicting events for the object having the given 'uid' and 1040 representation 'obj'. 1041 """ 1042 1043 page = self.page 1044 1045 # Obtain the user's timezone. 1046 1047 tzid = self.get_tzid() 1048 1049 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 1050 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 1051 1052 # Indicate whether there are conflicting events. 1053 1054 freebusy = self.store.get_freebusy(self.user) 1055 1056 if freebusy: 1057 1058 # Obtain any time zone details from the suggested event. 1059 1060 _dtstart, attr = obj.get_item("DTSTART") 1061 tzid = attr.get("TZID", tzid) 1062 1063 # Show any conflicts. 1064 1065 conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid] 1066 1067 if conflicts: 1068 page.p("This event conflicts with others:") 1069 1070 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1071 page.thead() 1072 page.tr() 1073 page.th("Event") 1074 page.th("Start") 1075 page.th("End") 1076 page.tr.close() 1077 page.thead.close() 1078 page.tbody() 1079 1080 for t in conflicts: 1081 start, end, found_uid = t[:3] 1082 1083 # Provide details of any conflicting event. 1084 1085 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 1086 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 1087 1088 page.tr() 1089 1090 # Show the event summary for the conflicting event. 1091 1092 page.td() 1093 1094 found_obj = self._get_object(found_uid) 1095 if found_obj: 1096 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 1097 else: 1098 page.add("No details available") 1099 1100 page.td.close() 1101 1102 page.td(start) 1103 page.td(end) 1104 1105 page.tr.close() 1106 1107 page.tbody.close() 1108 page.table.close() 1109 1110 def show_requests_on_page(self): 1111 1112 "Show requests for the current user." 1113 1114 # NOTE: This list could be more informative, but it is envisaged that 1115 # NOTE: the requests would be visited directly anyway. 1116 1117 requests = self._get_requests() 1118 1119 self.page.div(id="pending-requests") 1120 1121 if requests: 1122 self.page.p("Pending requests:") 1123 1124 self.page.ul() 1125 1126 for request in requests: 1127 obj = self._get_object(request) 1128 if obj: 1129 self.page.li() 1130 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 1131 self.page.li.close() 1132 1133 self.page.ul.close() 1134 1135 else: 1136 self.page.p("There are no pending requests.") 1137 1138 self.page.div.close() 1139 1140 def show_participants_on_page(self): 1141 1142 "Show participants for scheduling purposes." 1143 1144 args = self.env.get_args() 1145 participants = args.get("participants", []) 1146 1147 try: 1148 for name, value in args.items(): 1149 if name.startswith("remove-participant-"): 1150 i = int(name[len("remove-participant-"):]) 1151 del participants[i] 1152 break 1153 except ValueError: 1154 pass 1155 1156 # Trim empty participants. 1157 1158 while participants and not participants[-1].strip(): 1159 participants.pop() 1160 1161 # Show any specified participants together with controls to remove and 1162 # add participants. 1163 1164 self.page.div(id="participants") 1165 1166 self.page.p("Participants for scheduling:") 1167 1168 for i, participant in enumerate(participants): 1169 self.page.p() 1170 self.page.input(name="participants", type="text", value=participant) 1171 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1172 self.page.p.close() 1173 1174 self.page.p() 1175 self.page.input(name="participants", type="text") 1176 self.page.input(name="add-participant", type="submit", value="Add") 1177 self.page.p.close() 1178 1179 self.page.div.close() 1180 1181 return participants 1182 1183 # Full page output methods. 1184 1185 def show_object(self, path_info): 1186 1187 "Show an object request using the given 'path_info' for the current user." 1188 1189 uid = self._get_uid(path_info) 1190 obj = self._get_object(uid) 1191 1192 if not obj: 1193 return False 1194 1195 error = self.handle_request(uid, obj) 1196 1197 if not error: 1198 return True 1199 1200 self.new_page(title="Event") 1201 self.show_object_on_page(uid, obj, error) 1202 1203 return True 1204 1205 def show_calendar(self): 1206 1207 "Show the calendar for the current user." 1208 1209 handled = self.handle_newevent() 1210 1211 self.new_page(title="Calendar") 1212 page = self.page 1213 1214 # Form controls are used in various places on the calendar page. 1215 1216 page.form(method="POST") 1217 1218 self.show_requests_on_page() 1219 participants = self.show_participants_on_page() 1220 1221 # Show a button for scheduling a new event. 1222 1223 page.p(class_="controls") 1224 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 1225 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1226 page.p.close() 1227 1228 # Show controls for hiding empty days and busy slots. 1229 # The positioning of the control, paragraph and table are important here. 1230 1231 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1232 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1233 1234 page.p(class_="controls") 1235 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1236 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1237 page.label("Show empty days", for_="showdays", class_="showdays disable") 1238 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1239 page.p.close() 1240 1241 freebusy = self.store.get_freebusy(self.user) 1242 1243 if not freebusy: 1244 page.p("No events scheduled.") 1245 return 1246 1247 # Obtain the user's timezone. 1248 1249 tzid = self.get_tzid() 1250 1251 # Day view: start at the earliest known day and produce days until the 1252 # latest known day, perhaps with expandable sections of empty days. 1253 1254 # Month view: start at the earliest known month and produce months until 1255 # the latest known month, perhaps with expandable sections of empty 1256 # months. 1257 1258 # Details of users to invite to new events could be superimposed on the 1259 # calendar. 1260 1261 # Requests are listed and linked to their tentative positions in the 1262 # calendar. Other participants are also shown. 1263 1264 request_summary = self._get_request_summary() 1265 1266 period_groups = [request_summary, freebusy] 1267 period_group_types = ["request", "freebusy"] 1268 period_group_sources = ["Pending requests", "Your schedule"] 1269 1270 for i, participant in enumerate(participants): 1271 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1272 period_group_types.append("freebusy-part%d" % i) 1273 period_group_sources.append(participant) 1274 1275 groups = [] 1276 group_columns = [] 1277 group_types = period_group_types 1278 group_sources = period_group_sources 1279 all_points = set() 1280 1281 # Obtain time point information for each group of periods. 1282 1283 for periods in period_groups: 1284 periods = convert_periods(periods, tzid) 1285 1286 # Get the time scale with start and end points. 1287 1288 scale = get_scale(periods) 1289 1290 # Get the time slots for the periods. 1291 1292 slots = get_slots(scale) 1293 1294 # Add start of day time points for multi-day periods. 1295 1296 add_day_start_points(slots, tzid) 1297 1298 # Record the slots and all time points employed. 1299 1300 groups.append(slots) 1301 all_points.update([point for point, active in slots]) 1302 1303 # Partition the groups into days. 1304 1305 days = {} 1306 partitioned_groups = [] 1307 partitioned_group_types = [] 1308 partitioned_group_sources = [] 1309 1310 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1311 1312 # Propagate time points to all groups of time slots. 1313 1314 add_slots(slots, all_points) 1315 1316 # Count the number of columns employed by the group. 1317 1318 columns = 0 1319 1320 # Partition the time slots by day. 1321 1322 partitioned = {} 1323 1324 for day, day_slots in partition_by_day(slots).items(): 1325 intervals = [] 1326 last = None 1327 1328 for point, active in day_slots: 1329 columns = max(columns, len(active)) 1330 if last: 1331 intervals.append((last, point)) 1332 last = point 1333 1334 if last: 1335 intervals.append((last, None)) 1336 1337 if not days.has_key(day): 1338 days[day] = set() 1339 1340 # Convert each partition to a mapping from points to active 1341 # periods. 1342 1343 partitioned[day] = dict(day_slots) 1344 1345 # Record the divisions or intervals within each day. 1346 1347 days[day].update(intervals) 1348 1349 if group_type != "request" or columns: 1350 group_columns.append(columns) 1351 partitioned_groups.append(partitioned) 1352 partitioned_group_types.append(group_type) 1353 partitioned_group_sources.append(group_source) 1354 1355 # Add empty days. 1356 1357 add_empty_days(days, tzid) 1358 1359 # Show the controls permitting day selection. 1360 1361 self.show_calendar_day_controls(days) 1362 1363 # Show the calendar itself. 1364 1365 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1366 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1367 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1368 page.table.close() 1369 1370 # End the form region. 1371 1372 page.form.close() 1373 1374 # More page fragment methods. 1375 1376 def show_calendar_day_controls(self, days): 1377 1378 "Show controls for the given 'days' in the calendar." 1379 1380 page = self.page 1381 slots = self.env.get_args().get("slot", []) 1382 1383 for day in days: 1384 value, identifier = self._day_value_and_identifier(day) 1385 self._slot_selector(value, identifier, slots) 1386 1387 # Generate a dynamic stylesheet to allow day selections to colour 1388 # specific days. 1389 # NOTE: The style details need to be coordinated with the static 1390 # NOTE: stylesheet. 1391 1392 page.style(type="text/css") 1393 1394 for day in days: 1395 daystr = format_datetime(day) 1396 page.add("""\ 1397 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1398 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1399 background-color: #5f4; 1400 text-decoration: underline; 1401 } 1402 """ % (daystr, daystr, daystr, daystr)) 1403 1404 page.style.close() 1405 1406 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1407 1408 """ 1409 Show headings for the participants and other scheduling contributors, 1410 defined by 'group_types', 'group_sources' and 'group_columns'. 1411 """ 1412 1413 page = self.page 1414 1415 page.colgroup(span=1, id="columns-timeslot") 1416 1417 for group_type, columns in zip(group_types, group_columns): 1418 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1419 1420 page.thead() 1421 page.tr() 1422 page.th("", class_="emptyheading") 1423 1424 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1425 page.th(source, 1426 class_=(group_type == "request" and "requestheading" or "participantheading"), 1427 colspan=max(columns, 1)) 1428 1429 page.tr.close() 1430 page.thead.close() 1431 1432 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1433 1434 """ 1435 Show calendar days, defined by a collection of 'days', the contributing 1436 period information as 'partitioned_groups' (partitioned by day), the 1437 'partitioned_group_types' indicating the kind of contribution involved, 1438 and the 'group_columns' defining the number of columns in each group. 1439 """ 1440 1441 page = self.page 1442 1443 # Determine the number of columns required. Where participants provide 1444 # no columns for events, one still needs to be provided for the 1445 # participant itself. 1446 1447 all_columns = sum([max(columns, 1) for columns in group_columns]) 1448 1449 # Determine the days providing time slots. 1450 1451 all_days = days.items() 1452 all_days.sort() 1453 1454 # Produce a heading and time points for each day. 1455 1456 for day, intervals in all_days: 1457 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1458 is_empty = True 1459 1460 for slots in groups_for_day: 1461 if not slots: 1462 continue 1463 1464 for active in slots.values(): 1465 if active: 1466 is_empty = False 1467 break 1468 1469 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1470 page.tr() 1471 page.th(class_="dayheading container", colspan=all_columns+1) 1472 self._day_heading(day) 1473 page.th.close() 1474 page.tr.close() 1475 page.thead.close() 1476 1477 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1478 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1479 page.tbody.close() 1480 1481 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1482 1483 """ 1484 Show the time 'intervals' along with period information from the given 1485 'groups', having the indicated 'group_types', each with the number of 1486 columns given by 'group_columns'. 1487 """ 1488 1489 page = self.page 1490 1491 # Obtain the user's timezone. 1492 1493 tzid = self.get_tzid() 1494 1495 # Produce a row for each interval. 1496 1497 intervals = list(intervals) 1498 intervals.sort() 1499 1500 for point, endpoint in intervals: 1501 continuation = point == get_start_of_day(point, tzid) 1502 1503 # Some rows contain no period details and are marked as such. 1504 1505 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1506 1507 css = " ".join( 1508 ["slot"] + 1509 (have_active and ["busy"] or ["empty"]) + 1510 (continuation and ["daystart"] or []) 1511 ) 1512 1513 page.tr(class_=css) 1514 page.th(class_="timeslot") 1515 self._time_point(point, endpoint) 1516 page.th.close() 1517 1518 # Obtain slots for the time point from each group. 1519 1520 for columns, slots, group_type in zip(group_columns, groups, group_types): 1521 active = slots and slots.get(point) 1522 1523 # Where no periods exist for the given time interval, generate 1524 # an empty cell. Where a participant provides no periods at all, 1525 # the colspan is adjusted to be 1, not 0. 1526 1527 if not active: 1528 page.td(class_="empty container", colspan=max(columns, 1)) 1529 self._empty_slot(point, endpoint) 1530 page.td.close() 1531 continue 1532 1533 slots = slots.items() 1534 slots.sort() 1535 spans = get_spans(slots) 1536 1537 empty = 0 1538 1539 # Show a column for each active period. 1540 1541 for t in active: 1542 if t and len(t) >= 2: 1543 1544 # Flush empty slots preceding this one. 1545 1546 if empty: 1547 page.td(class_="empty container", colspan=empty) 1548 self._empty_slot(point, endpoint) 1549 page.td.close() 1550 empty = 0 1551 1552 start, end, uid, key = get_freebusy_details(t) 1553 span = spans[key] 1554 1555 # Produce a table cell only at the start of the period 1556 # or when continued at the start of a day. 1557 1558 if point == start or continuation: 1559 1560 obj = self._get_object(uid) 1561 1562 has_continued = continuation and point != start 1563 will_continue = not ends_on_same_day(point, end, tzid) 1564 is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user 1565 1566 css = " ".join( 1567 ["event"] + 1568 (has_continued and ["continued"] or []) + 1569 (will_continue and ["continues"] or []) + 1570 (is_organiser and ["organising"] or ["attending"]) 1571 ) 1572 1573 # Only anchor the first cell of events. 1574 1575 if point == start: 1576 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1577 else: 1578 page.td(class_=css, rowspan=span) 1579 1580 if not obj: 1581 page.span("(Participant is busy)") 1582 else: 1583 summary = obj.get_value("SUMMARY") 1584 1585 # Only link to events if they are not being 1586 # updated by requests. 1587 1588 if uid in self._get_requests() and group_type != "request": 1589 page.span(summary) 1590 else: 1591 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1592 page.a(summary, href=href) 1593 1594 page.td.close() 1595 else: 1596 empty += 1 1597 1598 # Pad with empty columns. 1599 1600 empty = columns - len(active) 1601 1602 if empty: 1603 page.td(class_="empty container", colspan=empty) 1604 self._empty_slot(point, endpoint) 1605 page.td.close() 1606 1607 page.tr.close() 1608 1609 def _day_heading(self, day): 1610 1611 """ 1612 Generate a heading for 'day' of the following form: 1613 1614 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1615 """ 1616 1617 page = self.page 1618 daystr = format_datetime(day) 1619 value, identifier = self._day_value_and_identifier(day) 1620 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1621 1622 def _time_point(self, point, endpoint): 1623 1624 """ 1625 Generate headings for the 'point' to 'endpoint' period of the following 1626 form: 1627 1628 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1629 <span class="endpoint">10:00:00 CET</span> 1630 """ 1631 1632 page = self.page 1633 tzid = self.get_tzid() 1634 daystr = format_datetime(point.date()) 1635 value, identifier = self._slot_value_and_identifier(point, endpoint) 1636 slots = self.env.get_args().get("slot", []) 1637 self._slot_selector(value, identifier, slots) 1638 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1639 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1640 1641 def _slot_selector(self, value, identifier, slots): 1642 reset = self.env.get_args().has_key("reset") 1643 page = self.page 1644 if not reset and value in slots: 1645 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1646 else: 1647 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1648 1649 def _empty_slot(self, point, endpoint): 1650 page = self.page 1651 value, identifier = self._slot_value_and_identifier(point, endpoint) 1652 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1653 1654 def _day_value_and_identifier(self, day): 1655 value = "%s-" % format_datetime(day) 1656 identifier = "day-%s" % value 1657 return value, identifier 1658 1659 def _slot_value_and_identifier(self, point, endpoint): 1660 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1661 identifier = "slot-%s" % value 1662 return value, identifier 1663 1664 def _show_menu(self, name, default, items, class_=""): 1665 page = self.page 1666 values = self.env.get_args().get(name, [default]) 1667 page.select(name=name) 1668 for v, label in items: 1669 if v in values: 1670 page.option(label, value=v, selected="selected", class_=class_) 1671 else: 1672 page.option(label, value=v, class_=class_) 1673 page.select.close() 1674 1675 def _show_date_controls(self, name, default, attr, tzid): 1676 1677 """ 1678 Show date controls for a field with the given 'name' and 'default' value 1679 and 'attr', with the given 'tzid' being used if no other time regime 1680 information is provided. 1681 """ 1682 1683 page = self.page 1684 args = self.env.get_args() 1685 1686 event_tzid = attr.get("TZID", tzid) 1687 dt = get_datetime(default, attr) 1688 1689 # Show dates for up to one week around the current date. 1690 1691 base = get_date(dt) 1692 items = [] 1693 for i in range(-7, 8): 1694 d = base + timedelta(i) 1695 items.append((format_datetime(d), self.format_date(d, "full"))) 1696 1697 self._show_menu("%s-date" % name, format_datetime(base), items) 1698 1699 # Show time details. 1700 1701 dt_time = isinstance(dt, datetime) and dt or None 1702 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1703 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1704 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1705 1706 page.span(class_="time enabled") 1707 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1708 page.add(":") 1709 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1710 page.add(":") 1711 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1712 page.add(" ") 1713 self._show_menu("%s-tzid" % name, event_tzid, 1714 [(event_tzid, event_tzid)] + ( 1715 event_tzid != tzid and [(tzid, tzid)] or [] 1716 )) 1717 page.span.close() 1718 1719 # Incoming HTTP request direction. 1720 1721 def select_action(self): 1722 1723 "Select the desired action and show the result." 1724 1725 path_info = self.env.get_path_info().strip("/") 1726 1727 if not path_info: 1728 self.show_calendar() 1729 elif self.show_object(path_info): 1730 pass 1731 else: 1732 self.no_page() 1733 1734 def __call__(self): 1735 1736 "Interpret a request and show an appropriate response." 1737 1738 if not self.user: 1739 self.no_user() 1740 else: 1741 self.select_action() 1742 1743 # Write the headers and actual content. 1744 1745 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1746 print >>self.out 1747 self.out.write(unicode(self.page).encode(self.encoding)) 1748 1749 if __name__ == "__main__": 1750 Manager()() 1751 1752 # vim: tabstop=4 expandtab shiftwidth=4