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