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