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 = get_uri(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") or []) 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 args = self.env.get_args() 702 703 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 704 705 attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", [])) 706 is_attendee = self.user in attendees 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("As attendee, you can perform the following:") 737 738 page.p() 739 page.input(name="save", type="submit", value="Save") 740 page.add(" ") 741 page.input(name="discard", type="submit", value="Discard") 742 page.p.close() 743 744 property_items = [ 745 ("SUMMARY", "Summary"), 746 ("DTSTART", "Start"), 747 ("DTEND", "End"), 748 ("ORGANIZER", "Organiser"), 749 ("ATTENDEE", "Attendee"), 750 ] 751 752 partstat_items = [ 753 ("NEEDS-ACTION", "Not confirmed"), 754 ("ACCEPTED", "Attending"), 755 ("TENTATIVE", "Tentatively attending"), 756 ("DECLINED", "Not attending"), 757 ("DELEGATED", "Delegated"), 758 ] 759 760 def show_object_on_page(self, uid, obj, error=None): 761 762 """ 763 Show the calendar object with the given 'uid' and representation 'obj' 764 on the current page. If 'error' is given, show a suitable message. 765 """ 766 767 page = self.page 768 page.form(method="POST") 769 770 # Obtain the user's timezone. 771 772 tzid = self.get_tzid() 773 774 # Provide controls to change the displayed object. 775 776 args = self.env.get_args() 777 778 # Add or remove new attendees. 779 # This does not affect the stored object. 780 781 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 782 new_attendees = args.get("added", []) 783 new_attendee = args.get("attendee", [""])[0] 784 785 if args.has_key("add"): 786 if new_attendee.strip(): 787 new_attendee = get_uri(new_attendee.strip()) 788 if new_attendee not in new_attendees and new_attendee not in existing_attendees: 789 new_attendees.append(new_attendee) 790 new_attendee = "" 791 792 if args.has_key("removenew"): 793 removed_attendee = args["removenew"][0] 794 if removed_attendee in new_attendees: 795 new_attendees.remove(removed_attendee) 796 797 # Configure the start and end datetimes. 798 799 dtend_control = args.get("dtend-control", [None])[0] 800 dttimes_control = args.get("dttimes-control", [None])[0] 801 with_time = dttimes_control == "enable" 802 803 t = self.handle_date_controls("dtstart", with_time) 804 if t: 805 dtstart, dtstart_attr = t 806 else: 807 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 808 809 if dtend_control == "enable": 810 t = self.handle_date_controls("dtend", with_time) 811 if t: 812 dtend, dtend_attr = t 813 else: 814 dtend, dtend_attr = None, {} 815 elif dtend_control == "disable": 816 dtend, dtend_attr = None, {} 817 else: 818 dtend, dtend_attr = obj.get_datetime_item("DTEND") 819 820 # Change end dates to refer to the actual dates, not the iCalendar 821 # "next day" dates. 822 823 if dtend and not isinstance(dtend, datetime): 824 dtend -= timedelta(1) 825 826 # Show the end datetime controls if already active or if an object needs 827 # them. 828 829 dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend 830 dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) 831 832 if dtend_enabled: 833 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 834 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 835 else: 836 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 837 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 838 839 if dttimes_enabled: 840 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") 841 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") 842 else: 843 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") 844 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") 845 846 # Provide a summary of the object. 847 848 page.table(class_="object", cellspacing=5, cellpadding=5) 849 page.thead() 850 page.tr() 851 page.th("Event", class_="mainheading", colspan=2) 852 page.tr.close() 853 page.thead.close() 854 page.tbody() 855 856 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 857 858 for name, label in self.property_items: 859 page.tr() 860 861 # Handle datetimes specially. 862 863 if name in ["DTSTART", "DTEND"]: 864 field = name.lower() 865 866 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or "")) 867 868 # Obtain the datetime. 869 870 if name == "DTSTART": 871 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 872 873 # Where no end datetime exists, use the start datetime as the 874 # basis of any potential datetime specified if dt-control is 875 # set. 876 877 else: 878 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 879 880 # Show controls for editing as organiser. 881 882 if is_organiser: 883 value = format_datetime(dt) 884 885 page.td(class_="objectvalue %s" % field) 886 if name == "DTEND": 887 page.div(class_="dt disabled") 888 page.label("Specify end date", for_="dtend-enable", class_="enable") 889 page.div.close() 890 891 page.div(class_="dt enabled") 892 self._show_date_controls(field, value, attr, tzid) 893 if name == "DTSTART": 894 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 895 page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") 896 elif name == "DTEND": 897 page.label("End on same day", for_="dtend-disable", class_="disable") 898 page.div.close() 899 900 page.td.close() 901 902 # Show a label as attendee. 903 904 else: 905 page.td(self.format_datetime(dt, "full")) 906 907 page.tr.close() 908 909 # Handle the summary specially. 910 911 elif name == "SUMMARY": 912 value = args.get("summary", [obj.get_value(name)])[0] 913 914 page.th(label, class_="objectheading") 915 page.td() 916 if is_organiser: 917 page.input(name="summary", type="text", value=value, size=80) 918 else: 919 page.add(value) 920 page.td.close() 921 page.tr.close() 922 923 # Handle potentially many values. 924 925 else: 926 items = obj.get_items(name) or [] 927 rowspan = len(items) 928 929 if name == "ATTENDEE": 930 rowspan += len(new_attendees) + 1 931 elif not items: 932 continue 933 934 page.th(label, class_="objectheading", rowspan=rowspan) 935 936 first = True 937 938 for i, (value, attr) in enumerate(items): 939 if not first: 940 page.tr() 941 else: 942 first = False 943 944 if name in ("ATTENDEE", "ORGANIZER"): 945 value = get_uri(value) 946 947 page.td(class_="objectvalue") 948 page.add(value) 949 page.add(" ") 950 951 partstat = attr.get("PARTSTAT") 952 if value == self.user and (not is_organiser or name == "ORGANIZER"): 953 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 954 else: 955 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 956 957 if is_organiser and name == "ATTENDEE": 958 if value in args.get("remove", []): 959 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") 960 else: 961 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") 962 page.label("Remove", for_="remove-%d" % i, class_="remove") 963 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 964 965 else: 966 page.td(class_="objectvalue") 967 page.add(value) 968 969 page.td.close() 970 page.tr.close() 971 972 # Allow more attendees to be specified. 973 974 if is_organiser and name == "ATTENDEE": 975 for i, attendee in enumerate(new_attendees): 976 if not first: 977 page.tr() 978 else: 979 first = False 980 981 page.td() 982 page.input(name="added", type="value", value=attendee) 983 page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove") 984 page.label("Remove", for_="removenew-%d" % i, class_="remove") 985 page.td.close() 986 page.tr.close() 987 988 if not first: 989 page.tr() 990 991 page.td() 992 page.input(name="attendee", type="value", value=new_attendee) 993 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 994 page.label("Add", for_="add-%d" % i, class_="add") 995 page.td.close() 996 page.tr.close() 997 998 page.tbody.close() 999 page.table.close() 1000 1001 self.show_recurrences(obj) 1002 self.show_conflicting_events(uid, obj) 1003 self.show_request_controls(obj) 1004 1005 page.form.close() 1006 1007 def show_recurrences(self, obj): 1008 1009 "Show recurrences for the object having the given representation 'obj'." 1010 1011 page = self.page 1012 1013 # Obtain the user's timezone. 1014 1015 tzid = self.get_tzid() 1016 1017 window_size = 100 1018 1019 periods = obj.get_periods(self.get_tzid(), window_size) 1020 1021 if len(periods) == 1: 1022 return 1023 1024 page.p("This event occurs on the following occasions within the next %d days:" % window_size) 1025 1026 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1027 page.thead() 1028 page.tr() 1029 page.th("Start") 1030 page.th("End") 1031 page.tr.close() 1032 page.thead.close() 1033 page.tbody() 1034 1035 for start, end in periods: 1036 page.tr() 1037 page.td(self.format_datetime(start, "long")) 1038 page.td(self.format_datetime(end, "long")) 1039 page.tr.close() 1040 1041 page.tbody.close() 1042 page.table.close() 1043 1044 def show_conflicting_events(self, uid, obj): 1045 1046 """ 1047 Show conflicting events for the object having the given 'uid' and 1048 representation 'obj'. 1049 """ 1050 1051 page = self.page 1052 1053 # Obtain the user's timezone. 1054 1055 tzid = self.get_tzid() 1056 1057 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 1058 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 1059 1060 # Indicate whether there are conflicting events. 1061 1062 freebusy = self.store.get_freebusy(self.user) 1063 1064 if freebusy: 1065 1066 # Obtain any time zone details from the suggested event. 1067 1068 _dtstart, attr = obj.get_item("DTSTART") 1069 tzid = attr.get("TZID", tzid) 1070 1071 # Show any conflicts. 1072 1073 conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid] 1074 1075 if conflicts: 1076 page.p("This event conflicts with others:") 1077 1078 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1079 page.thead() 1080 page.tr() 1081 page.th("Event") 1082 page.th("Start") 1083 page.th("End") 1084 page.tr.close() 1085 page.thead.close() 1086 page.tbody() 1087 1088 for t in conflicts: 1089 start, end, found_uid = t[:3] 1090 1091 # Provide details of any conflicting event. 1092 1093 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 1094 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 1095 1096 page.tr() 1097 1098 # Show the event summary for the conflicting event. 1099 1100 page.td() 1101 1102 found_obj = self._get_object(found_uid) 1103 if found_obj: 1104 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 1105 else: 1106 page.add("No details available") 1107 1108 page.td.close() 1109 1110 page.td(start) 1111 page.td(end) 1112 1113 page.tr.close() 1114 1115 page.tbody.close() 1116 page.table.close() 1117 1118 def show_requests_on_page(self): 1119 1120 "Show requests for the current user." 1121 1122 # NOTE: This list could be more informative, but it is envisaged that 1123 # NOTE: the requests would be visited directly anyway. 1124 1125 requests = self._get_requests() 1126 1127 self.page.div(id="pending-requests") 1128 1129 if requests: 1130 self.page.p("Pending requests:") 1131 1132 self.page.ul() 1133 1134 for request in requests: 1135 obj = self._get_object(request) 1136 if obj: 1137 self.page.li() 1138 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 1139 self.page.li.close() 1140 1141 self.page.ul.close() 1142 1143 else: 1144 self.page.p("There are no pending requests.") 1145 1146 self.page.div.close() 1147 1148 def show_participants_on_page(self): 1149 1150 "Show participants for scheduling purposes." 1151 1152 args = self.env.get_args() 1153 participants = args.get("participants", []) 1154 1155 try: 1156 for name, value in args.items(): 1157 if name.startswith("remove-participant-"): 1158 i = int(name[len("remove-participant-"):]) 1159 del participants[i] 1160 break 1161 except ValueError: 1162 pass 1163 1164 # Trim empty participants. 1165 1166 while participants and not participants[-1].strip(): 1167 participants.pop() 1168 1169 # Show any specified participants together with controls to remove and 1170 # add participants. 1171 1172 self.page.div(id="participants") 1173 1174 self.page.p("Participants for scheduling:") 1175 1176 for i, participant in enumerate(participants): 1177 self.page.p() 1178 self.page.input(name="participants", type="text", value=participant) 1179 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1180 self.page.p.close() 1181 1182 self.page.p() 1183 self.page.input(name="participants", type="text") 1184 self.page.input(name="add-participant", type="submit", value="Add") 1185 self.page.p.close() 1186 1187 self.page.div.close() 1188 1189 return participants 1190 1191 # Full page output methods. 1192 1193 def show_object(self, path_info): 1194 1195 "Show an object request using the given 'path_info' for the current user." 1196 1197 uid = self._get_uid(path_info) 1198 obj = self._get_object(uid) 1199 1200 if not obj: 1201 return False 1202 1203 error = self.handle_request(uid, obj) 1204 1205 if not error: 1206 return True 1207 1208 self.new_page(title="Event") 1209 self.show_object_on_page(uid, obj, error) 1210 1211 return True 1212 1213 def show_calendar(self): 1214 1215 "Show the calendar for the current user." 1216 1217 handled = self.handle_newevent() 1218 1219 self.new_page(title="Calendar") 1220 page = self.page 1221 1222 # Form controls are used in various places on the calendar page. 1223 1224 page.form(method="POST") 1225 1226 self.show_requests_on_page() 1227 participants = self.show_participants_on_page() 1228 1229 # Show a button for scheduling a new event. 1230 1231 page.p(class_="controls") 1232 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 1233 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1234 page.p.close() 1235 1236 # Show controls for hiding empty days and busy slots. 1237 # The positioning of the control, paragraph and table are important here. 1238 1239 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1240 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1241 1242 page.p(class_="controls") 1243 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1244 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1245 page.label("Show empty days", for_="showdays", class_="showdays disable") 1246 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1247 page.p.close() 1248 1249 freebusy = self.store.get_freebusy(self.user) 1250 1251 if not freebusy: 1252 page.p("No events scheduled.") 1253 return 1254 1255 # Obtain the user's timezone. 1256 1257 tzid = self.get_tzid() 1258 1259 # Day view: start at the earliest known day and produce days until the 1260 # latest known day, perhaps with expandable sections of empty days. 1261 1262 # Month view: start at the earliest known month and produce months until 1263 # the latest known month, perhaps with expandable sections of empty 1264 # months. 1265 1266 # Details of users to invite to new events could be superimposed on the 1267 # calendar. 1268 1269 # Requests are listed and linked to their tentative positions in the 1270 # calendar. Other participants are also shown. 1271 1272 request_summary = self._get_request_summary() 1273 1274 period_groups = [request_summary, freebusy] 1275 period_group_types = ["request", "freebusy"] 1276 period_group_sources = ["Pending requests", "Your schedule"] 1277 1278 for i, participant in enumerate(participants): 1279 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1280 period_group_types.append("freebusy-part%d" % i) 1281 period_group_sources.append(participant) 1282 1283 groups = [] 1284 group_columns = [] 1285 group_types = period_group_types 1286 group_sources = period_group_sources 1287 all_points = set() 1288 1289 # Obtain time point information for each group of periods. 1290 1291 for periods in period_groups: 1292 periods = convert_periods(periods, tzid) 1293 1294 # Get the time scale with start and end points. 1295 1296 scale = get_scale(periods) 1297 1298 # Get the time slots for the periods. 1299 1300 slots = get_slots(scale) 1301 1302 # Add start of day time points for multi-day periods. 1303 1304 add_day_start_points(slots, tzid) 1305 1306 # Record the slots and all time points employed. 1307 1308 groups.append(slots) 1309 all_points.update([point for point, active in slots]) 1310 1311 # Partition the groups into days. 1312 1313 days = {} 1314 partitioned_groups = [] 1315 partitioned_group_types = [] 1316 partitioned_group_sources = [] 1317 1318 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1319 1320 # Propagate time points to all groups of time slots. 1321 1322 add_slots(slots, all_points) 1323 1324 # Count the number of columns employed by the group. 1325 1326 columns = 0 1327 1328 # Partition the time slots by day. 1329 1330 partitioned = {} 1331 1332 for day, day_slots in partition_by_day(slots).items(): 1333 intervals = [] 1334 last = None 1335 1336 for point, active in day_slots: 1337 columns = max(columns, len(active)) 1338 if last: 1339 intervals.append((last, point)) 1340 last = point 1341 1342 if last: 1343 intervals.append((last, None)) 1344 1345 if not days.has_key(day): 1346 days[day] = set() 1347 1348 # Convert each partition to a mapping from points to active 1349 # periods. 1350 1351 partitioned[day] = dict(day_slots) 1352 1353 # Record the divisions or intervals within each day. 1354 1355 days[day].update(intervals) 1356 1357 if group_type != "request" or columns: 1358 group_columns.append(columns) 1359 partitioned_groups.append(partitioned) 1360 partitioned_group_types.append(group_type) 1361 partitioned_group_sources.append(group_source) 1362 1363 # Add empty days. 1364 1365 add_empty_days(days, tzid) 1366 1367 # Show the controls permitting day selection. 1368 1369 self.show_calendar_day_controls(days) 1370 1371 # Show the calendar itself. 1372 1373 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1374 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1375 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1376 page.table.close() 1377 1378 # End the form region. 1379 1380 page.form.close() 1381 1382 # More page fragment methods. 1383 1384 def show_calendar_day_controls(self, days): 1385 1386 "Show controls for the given 'days' in the calendar." 1387 1388 page = self.page 1389 slots = self.env.get_args().get("slot", []) 1390 1391 for day in days: 1392 value, identifier = self._day_value_and_identifier(day) 1393 self._slot_selector(value, identifier, slots) 1394 1395 # Generate a dynamic stylesheet to allow day selections to colour 1396 # specific days. 1397 # NOTE: The style details need to be coordinated with the static 1398 # NOTE: stylesheet. 1399 1400 page.style(type="text/css") 1401 1402 for day in days: 1403 daystr = format_datetime(day) 1404 page.add("""\ 1405 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1406 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1407 background-color: #5f4; 1408 text-decoration: underline; 1409 } 1410 """ % (daystr, daystr, daystr, daystr)) 1411 1412 page.style.close() 1413 1414 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1415 1416 """ 1417 Show headings for the participants and other scheduling contributors, 1418 defined by 'group_types', 'group_sources' and 'group_columns'. 1419 """ 1420 1421 page = self.page 1422 1423 page.colgroup(span=1, id="columns-timeslot") 1424 1425 for group_type, columns in zip(group_types, group_columns): 1426 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1427 1428 page.thead() 1429 page.tr() 1430 page.th("", class_="emptyheading") 1431 1432 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1433 page.th(source, 1434 class_=(group_type == "request" and "requestheading" or "participantheading"), 1435 colspan=max(columns, 1)) 1436 1437 page.tr.close() 1438 page.thead.close() 1439 1440 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1441 1442 """ 1443 Show calendar days, defined by a collection of 'days', the contributing 1444 period information as 'partitioned_groups' (partitioned by day), the 1445 'partitioned_group_types' indicating the kind of contribution involved, 1446 and the 'group_columns' defining the number of columns in each group. 1447 """ 1448 1449 page = self.page 1450 1451 # Determine the number of columns required. Where participants provide 1452 # no columns for events, one still needs to be provided for the 1453 # participant itself. 1454 1455 all_columns = sum([max(columns, 1) for columns in group_columns]) 1456 1457 # Determine the days providing time slots. 1458 1459 all_days = days.items() 1460 all_days.sort() 1461 1462 # Produce a heading and time points for each day. 1463 1464 for day, intervals in all_days: 1465 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1466 is_empty = True 1467 1468 for slots in groups_for_day: 1469 if not slots: 1470 continue 1471 1472 for active in slots.values(): 1473 if active: 1474 is_empty = False 1475 break 1476 1477 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1478 page.tr() 1479 page.th(class_="dayheading container", colspan=all_columns+1) 1480 self._day_heading(day) 1481 page.th.close() 1482 page.tr.close() 1483 page.thead.close() 1484 1485 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1486 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1487 page.tbody.close() 1488 1489 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1490 1491 """ 1492 Show the time 'intervals' along with period information from the given 1493 'groups', having the indicated 'group_types', each with the number of 1494 columns given by 'group_columns'. 1495 """ 1496 1497 page = self.page 1498 1499 # Obtain the user's timezone. 1500 1501 tzid = self.get_tzid() 1502 1503 # Produce a row for each interval. 1504 1505 intervals = list(intervals) 1506 intervals.sort() 1507 1508 for point, endpoint in intervals: 1509 continuation = point == get_start_of_day(point, tzid) 1510 1511 # Some rows contain no period details and are marked as such. 1512 1513 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1514 1515 css = " ".join( 1516 ["slot"] + 1517 (have_active and ["busy"] or ["empty"]) + 1518 (continuation and ["daystart"] or []) 1519 ) 1520 1521 page.tr(class_=css) 1522 page.th(class_="timeslot") 1523 self._time_point(point, endpoint) 1524 page.th.close() 1525 1526 # Obtain slots for the time point from each group. 1527 1528 for columns, slots, group_type in zip(group_columns, groups, group_types): 1529 active = slots and slots.get(point) 1530 1531 # Where no periods exist for the given time interval, generate 1532 # an empty cell. Where a participant provides no periods at all, 1533 # the colspan is adjusted to be 1, not 0. 1534 1535 if not active: 1536 page.td(class_="empty container", colspan=max(columns, 1)) 1537 self._empty_slot(point, endpoint) 1538 page.td.close() 1539 continue 1540 1541 slots = slots.items() 1542 slots.sort() 1543 spans = get_spans(slots) 1544 1545 empty = 0 1546 1547 # Show a column for each active period. 1548 1549 for t in active: 1550 if t and len(t) >= 2: 1551 1552 # Flush empty slots preceding this one. 1553 1554 if empty: 1555 page.td(class_="empty container", colspan=empty) 1556 self._empty_slot(point, endpoint) 1557 page.td.close() 1558 empty = 0 1559 1560 start, end, uid, key = get_freebusy_details(t) 1561 span = spans[key] 1562 1563 # Produce a table cell only at the start of the period 1564 # or when continued at the start of a day. 1565 1566 if point == start or continuation: 1567 1568 obj = self._get_object(uid) 1569 1570 has_continued = continuation and point != start 1571 will_continue = not ends_on_same_day(point, end, tzid) 1572 is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user 1573 1574 css = " ".join( 1575 ["event"] + 1576 (has_continued and ["continued"] or []) + 1577 (will_continue and ["continues"] or []) + 1578 (is_organiser and ["organising"] or ["attending"]) 1579 ) 1580 1581 # Only anchor the first cell of events. 1582 1583 if point == start: 1584 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1585 else: 1586 page.td(class_=css, rowspan=span) 1587 1588 if not obj: 1589 page.span("(Participant is busy)") 1590 else: 1591 summary = obj.get_value("SUMMARY") 1592 1593 # Only link to events if they are not being 1594 # updated by requests. 1595 1596 if uid in self._get_requests() and group_type != "request": 1597 page.span(summary) 1598 else: 1599 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1600 page.a(summary, href=href) 1601 1602 page.td.close() 1603 else: 1604 empty += 1 1605 1606 # Pad with empty columns. 1607 1608 empty = columns - len(active) 1609 1610 if empty: 1611 page.td(class_="empty container", colspan=empty) 1612 self._empty_slot(point, endpoint) 1613 page.td.close() 1614 1615 page.tr.close() 1616 1617 def _day_heading(self, day): 1618 1619 """ 1620 Generate a heading for 'day' of the following form: 1621 1622 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1623 """ 1624 1625 page = self.page 1626 daystr = format_datetime(day) 1627 value, identifier = self._day_value_and_identifier(day) 1628 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1629 1630 def _time_point(self, point, endpoint): 1631 1632 """ 1633 Generate headings for the 'point' to 'endpoint' period of the following 1634 form: 1635 1636 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1637 <span class="endpoint">10:00:00 CET</span> 1638 """ 1639 1640 page = self.page 1641 tzid = self.get_tzid() 1642 daystr = format_datetime(point.date()) 1643 value, identifier = self._slot_value_and_identifier(point, endpoint) 1644 slots = self.env.get_args().get("slot", []) 1645 self._slot_selector(value, identifier, slots) 1646 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1647 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1648 1649 def _slot_selector(self, value, identifier, slots): 1650 reset = self.env.get_args().has_key("reset") 1651 page = self.page 1652 if not reset and value in slots: 1653 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1654 else: 1655 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1656 1657 def _empty_slot(self, point, endpoint): 1658 page = self.page 1659 value, identifier = self._slot_value_and_identifier(point, endpoint) 1660 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1661 1662 def _day_value_and_identifier(self, day): 1663 value = "%s-" % format_datetime(day) 1664 identifier = "day-%s" % value 1665 return value, identifier 1666 1667 def _slot_value_and_identifier(self, point, endpoint): 1668 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1669 identifier = "slot-%s" % value 1670 return value, identifier 1671 1672 def _show_menu(self, name, default, items, class_=""): 1673 page = self.page 1674 values = self.env.get_args().get(name, [default]) 1675 page.select(name=name, class_=class_) 1676 for v, label in items: 1677 if v in values: 1678 page.option(label, value=v, selected="selected") 1679 else: 1680 page.option(label, value=v) 1681 page.select.close() 1682 1683 def _show_date_controls(self, name, default, attr, tzid): 1684 1685 """ 1686 Show date controls for a field with the given 'name' and 'default' value 1687 and 'attr', with the given 'tzid' being used if no other time regime 1688 information is provided. 1689 """ 1690 1691 page = self.page 1692 args = self.env.get_args() 1693 1694 event_tzid = attr.get("TZID", tzid) 1695 dt = get_datetime(default, attr) 1696 1697 # Show dates for up to one week around the current date. 1698 1699 base = get_date(dt) 1700 items = [] 1701 for i in range(-7, 8): 1702 d = base + timedelta(i) 1703 items.append((format_datetime(d), self.format_date(d, "full"))) 1704 1705 self._show_menu("%s-date" % name, format_datetime(base), items) 1706 1707 # Show time details. 1708 1709 dt_time = isinstance(dt, datetime) and dt or None 1710 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1711 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1712 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1713 1714 page.span(class_="time enabled") 1715 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1716 page.add(":") 1717 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1718 page.add(":") 1719 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1720 page.add(" ") 1721 self._show_menu("%s-tzid" % name, event_tzid, 1722 [(event_tzid, event_tzid)] + ( 1723 event_tzid != tzid and [(tzid, tzid)] or [] 1724 )) 1725 page.span.close() 1726 1727 # Incoming HTTP request direction. 1728 1729 def select_action(self): 1730 1731 "Select the desired action and show the result." 1732 1733 path_info = self.env.get_path_info().strip("/") 1734 1735 if not path_info: 1736 self.show_calendar() 1737 elif self.show_object(path_info): 1738 pass 1739 else: 1740 self.no_page() 1741 1742 def __call__(self): 1743 1744 "Interpret a request and show an appropriate response." 1745 1746 if not self.user: 1747 self.no_user() 1748 else: 1749 self.select_action() 1750 1751 # Write the headers and actual content. 1752 1753 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1754 print >>self.out 1755 self.out.write(unicode(self.page).encode(self.encoding)) 1756 1757 if __name__ == "__main__": 1758 Manager()() 1759 1760 # vim: tabstop=4 expandtab shiftwidth=4