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