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