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