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