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