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 value = format_datetime(dt) 1132 page.td(class_="objectvalue dt%s" % (is_start_datetime and "start" or "end")) 1133 1134 if is_start_datetime: 1135 page.div(class_="dt enabled") 1136 self._show_date_controls("dtstart", value, attr) 1137 page.br() 1138 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 1139 page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") 1140 page.div.close() 1141 1142 else: 1143 page.div(class_="dt disabled") 1144 page.label("Specify end date", for_="dtend-enable", class_="enable") 1145 page.div.close() 1146 page.div(class_="dt enabled") 1147 self._show_date_controls("dtend", value, attr) 1148 page.br() 1149 page.label("End on same day", for_="dtend-disable", class_="disable") 1150 page.div.close() 1151 1152 page.td.close() 1153 1154 # Show a label as attendee. 1155 1156 else: 1157 page.td(self.format_datetime(dt, "full")) 1158 1159 def show_object_datetime_controls(self, start, end): 1160 1161 """ 1162 Show datetime-related controls if already active or if an object needs 1163 them for the given 'start' to 'end' period. 1164 """ 1165 1166 page = self.page 1167 args = self.env.get_args() 1168 1169 dtend_control = args.get("dtend-control", [None])[0] 1170 dttimes_control = args.get("dttimes-control", [None])[0] 1171 1172 dtend_enabled = dtend_control == "enable" or isinstance(end, datetime) or start != end 1173 dttimes_enabled = dttimes_control == "enable" or isinstance(start, datetime) or isinstance(end, datetime) 1174 1175 if dtend_enabled: 1176 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 1177 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 1178 else: 1179 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 1180 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 1181 1182 if dttimes_enabled: 1183 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") 1184 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") 1185 else: 1186 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") 1187 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") 1188 1189 def show_recurrences(self, obj): 1190 1191 "Show recurrences for the object having the given representation 'obj'." 1192 1193 page = self.page 1194 1195 # Obtain any parent object if this object is a specific recurrence. 1196 1197 uid = obj.get_value("UID") 1198 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 1199 1200 if recurrenceid: 1201 obj = self._get_object(uid) 1202 if not obj: 1203 return 1204 1205 page.p("This event modifies a recurring event.") 1206 1207 # Obtain the periods associated with the event in the user's time zone. 1208 1209 periods = obj.get_periods(self.get_tzid(), self.get_window_end()) 1210 recurrenceids = self._get_recurrences(uid) 1211 1212 if len(periods) == 1: 1213 return 1214 1215 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 1216 1217 page.table(cellspacing=5, cellpadding=5, class_="recurrences") 1218 page.thead() 1219 page.tr() 1220 page.th("Start") 1221 page.th("End") 1222 page.tr.close() 1223 page.thead.close() 1224 page.tbody() 1225 1226 for start, end in periods: 1227 start_utc = format_datetime(to_timezone(start, "UTC")) 1228 css = " ".join([ 1229 recurrenceids and start_utc in recurrenceids and "replaced" or "", 1230 recurrenceid and start_utc == recurrenceid and "affected" or "" 1231 ]) 1232 1233 page.tr() 1234 page.td(self.format_datetime(start, "long"), class_=css) 1235 page.td(self.format_datetime(end, "long"), class_=css) 1236 page.tr.close() 1237 1238 page.tbody.close() 1239 page.table.close() 1240 1241 def show_conflicting_events(self, uid, obj): 1242 1243 """ 1244 Show conflicting events for the object having the given 'uid' and 1245 representation 'obj'. 1246 """ 1247 1248 page = self.page 1249 1250 # Obtain the user's timezone. 1251 1252 tzid = self.get_tzid() 1253 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 1254 1255 # Indicate whether there are conflicting events. 1256 1257 freebusy = self.store.get_freebusy(self.user) 1258 1259 if freebusy: 1260 1261 # Obtain any time zone details from the suggested event. 1262 1263 _dtstart, attr = obj.get_item("DTSTART") 1264 tzid = attr.get("TZID", tzid) 1265 1266 # Show any conflicts. 1267 1268 conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid] 1269 1270 if conflicts: 1271 page.p("This event conflicts with others:") 1272 1273 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1274 page.thead() 1275 page.tr() 1276 page.th("Event") 1277 page.th("Start") 1278 page.th("End") 1279 page.tr.close() 1280 page.thead.close() 1281 page.tbody() 1282 1283 for t in conflicts: 1284 start, end, found_uid, transp, found_recurrenceid, summary = t[:6] 1285 1286 # Provide details of any conflicting event. 1287 1288 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 1289 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 1290 1291 page.tr() 1292 1293 # Show the event summary for the conflicting event. 1294 1295 page.td() 1296 page.a(summary, href=self.link_to(found_uid)) 1297 page.td.close() 1298 1299 page.td(start) 1300 page.td(end) 1301 1302 page.tr.close() 1303 1304 page.tbody.close() 1305 page.table.close() 1306 1307 def show_requests_on_page(self): 1308 1309 "Show requests for the current user." 1310 1311 page = self.page 1312 1313 # NOTE: This list could be more informative, but it is envisaged that 1314 # NOTE: the requests would be visited directly anyway. 1315 1316 requests = self._get_requests() 1317 1318 page.div(id="pending-requests") 1319 1320 if requests: 1321 page.p("Pending requests:") 1322 1323 page.ul() 1324 1325 for uid, recurrenceid in requests: 1326 obj = self._get_object(uid, recurrenceid) 1327 if obj: 1328 page.li() 1329 page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 1330 page.li.close() 1331 1332 page.ul.close() 1333 1334 else: 1335 page.p("There are no pending requests.") 1336 1337 page.div.close() 1338 1339 def show_participants_on_page(self): 1340 1341 "Show participants for scheduling purposes." 1342 1343 page = self.page 1344 args = self.env.get_args() 1345 participants = args.get("participants", []) 1346 1347 try: 1348 for name, value in args.items(): 1349 if name.startswith("remove-participant-"): 1350 i = int(name[len("remove-participant-"):]) 1351 del participants[i] 1352 break 1353 except ValueError: 1354 pass 1355 1356 # Trim empty participants. 1357 1358 while participants and not participants[-1].strip(): 1359 participants.pop() 1360 1361 # Show any specified participants together with controls to remove and 1362 # add participants. 1363 1364 page.div(id="participants") 1365 1366 page.p("Participants for scheduling:") 1367 1368 for i, participant in enumerate(participants): 1369 page.p() 1370 page.input(name="participants", type="text", value=participant) 1371 page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1372 page.p.close() 1373 1374 page.p() 1375 page.input(name="participants", type="text") 1376 page.input(name="add-participant", type="submit", value="Add") 1377 page.p.close() 1378 1379 page.div.close() 1380 1381 return participants 1382 1383 # Full page output methods. 1384 1385 def show_object(self, path_info): 1386 1387 "Show an object request using the given 'path_info' for the current user." 1388 1389 uid, recurrenceid = self._get_identifiers(path_info) 1390 obj = self._get_object(uid, recurrenceid) 1391 1392 if not obj: 1393 return False 1394 1395 error = self.handle_request(uid, recurrenceid, obj) 1396 1397 if not error: 1398 return True 1399 1400 self.new_page(title="Event") 1401 self.show_object_on_page(uid, obj, error) 1402 1403 return True 1404 1405 def show_calendar(self): 1406 1407 "Show the calendar for the current user." 1408 1409 handled = self.handle_newevent() 1410 1411 self.new_page(title="Calendar") 1412 page = self.page 1413 1414 # Form controls are used in various places on the calendar page. 1415 1416 page.form(method="POST") 1417 1418 self.show_requests_on_page() 1419 participants = self.show_participants_on_page() 1420 1421 # Show a button for scheduling a new event. 1422 1423 page.p(class_="controls") 1424 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 1425 page.p.close() 1426 1427 # Show controls for hiding empty days and busy slots. 1428 # The positioning of the control, paragraph and table are important here. 1429 1430 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1431 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1432 1433 page.p(class_="controls") 1434 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1435 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1436 page.label("Show empty days", for_="showdays", class_="showdays disable") 1437 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1438 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1439 page.label("Clear selections", for_="reset", class_="reset") 1440 page.p.close() 1441 1442 freebusy = self.store.get_freebusy(self.user) 1443 1444 if not freebusy: 1445 page.p("No events scheduled.") 1446 return 1447 1448 # Obtain the user's timezone. 1449 1450 tzid = self.get_tzid() 1451 1452 # Day view: start at the earliest known day and produce days until the 1453 # latest known day, perhaps with expandable sections of empty days. 1454 1455 # Month view: start at the earliest known month and produce months until 1456 # the latest known month, perhaps with expandable sections of empty 1457 # months. 1458 1459 # Details of users to invite to new events could be superimposed on the 1460 # calendar. 1461 1462 # Requests are listed and linked to their tentative positions in the 1463 # calendar. Other participants are also shown. 1464 1465 request_summary = self._get_request_summary() 1466 1467 period_groups = [request_summary, freebusy] 1468 period_group_types = ["request", "freebusy"] 1469 period_group_sources = ["Pending requests", "Your schedule"] 1470 1471 for i, participant in enumerate(participants): 1472 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1473 period_group_types.append("freebusy-part%d" % i) 1474 period_group_sources.append(participant) 1475 1476 groups = [] 1477 group_columns = [] 1478 group_types = period_group_types 1479 group_sources = period_group_sources 1480 all_points = set() 1481 1482 # Obtain time point information for each group of periods. 1483 1484 for periods in period_groups: 1485 periods = convert_periods(periods, tzid) 1486 1487 # Get the time scale with start and end points. 1488 1489 scale = get_scale(periods) 1490 1491 # Get the time slots for the periods. 1492 1493 slots = get_slots(scale) 1494 1495 # Add start of day time points for multi-day periods. 1496 1497 add_day_start_points(slots, tzid) 1498 1499 # Record the slots and all time points employed. 1500 1501 groups.append(slots) 1502 all_points.update([point for point, active in slots]) 1503 1504 # Partition the groups into days. 1505 1506 days = {} 1507 partitioned_groups = [] 1508 partitioned_group_types = [] 1509 partitioned_group_sources = [] 1510 1511 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1512 1513 # Propagate time points to all groups of time slots. 1514 1515 add_slots(slots, all_points) 1516 1517 # Count the number of columns employed by the group. 1518 1519 columns = 0 1520 1521 # Partition the time slots by day. 1522 1523 partitioned = {} 1524 1525 for day, day_slots in partition_by_day(slots).items(): 1526 1527 # Construct a list of time intervals within the day. 1528 1529 intervals = [] 1530 last = None 1531 1532 for point, active in day_slots: 1533 columns = max(columns, len(active)) 1534 if last: 1535 intervals.append((last, point)) 1536 last = point 1537 1538 if last: 1539 intervals.append((last, None)) 1540 1541 if not days.has_key(day): 1542 days[day] = set() 1543 1544 # Convert each partition to a mapping from points to active 1545 # periods. 1546 1547 partitioned[day] = dict(day_slots) 1548 1549 # Record the divisions or intervals within each day. 1550 1551 days[day].update(intervals) 1552 1553 # Only include the requests column if it provides objects. 1554 1555 if group_type != "request" or columns: 1556 group_columns.append(columns) 1557 partitioned_groups.append(partitioned) 1558 partitioned_group_types.append(group_type) 1559 partitioned_group_sources.append(group_source) 1560 1561 # Add empty days. 1562 1563 add_empty_days(days, tzid) 1564 1565 # Show the controls permitting day selection. 1566 1567 self.show_calendar_day_controls(days) 1568 1569 # Show the calendar itself. 1570 1571 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1572 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1573 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1574 page.table.close() 1575 1576 # End the form region. 1577 1578 page.form.close() 1579 1580 # More page fragment methods. 1581 1582 def show_calendar_day_controls(self, days): 1583 1584 "Show controls for the given 'days' in the calendar." 1585 1586 page = self.page 1587 slots = self.env.get_args().get("slot", []) 1588 1589 for day in days: 1590 value, identifier = self._day_value_and_identifier(day) 1591 self._slot_selector(value, identifier, slots) 1592 1593 # Generate a dynamic stylesheet to allow day selections to colour 1594 # specific days. 1595 # NOTE: The style details need to be coordinated with the static 1596 # NOTE: stylesheet. 1597 1598 page.style(type="text/css") 1599 1600 for day in days: 1601 daystr = format_datetime(day) 1602 page.add("""\ 1603 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1604 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1605 background-color: #5f4; 1606 text-decoration: underline; 1607 } 1608 """ % (daystr, daystr, daystr, daystr)) 1609 1610 page.style.close() 1611 1612 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1613 1614 """ 1615 Show headings for the participants and other scheduling contributors, 1616 defined by 'group_types', 'group_sources' and 'group_columns'. 1617 """ 1618 1619 page = self.page 1620 1621 page.colgroup(span=1, id="columns-timeslot") 1622 1623 for group_type, columns in zip(group_types, group_columns): 1624 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1625 1626 page.thead() 1627 page.tr() 1628 page.th("", class_="emptyheading") 1629 1630 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1631 page.th(source, 1632 class_=(group_type == "request" and "requestheading" or "participantheading"), 1633 colspan=max(columns, 1)) 1634 1635 page.tr.close() 1636 page.thead.close() 1637 1638 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1639 1640 """ 1641 Show calendar days, defined by a collection of 'days', the contributing 1642 period information as 'partitioned_groups' (partitioned by day), the 1643 'partitioned_group_types' indicating the kind of contribution involved, 1644 and the 'group_columns' defining the number of columns in each group. 1645 """ 1646 1647 page = self.page 1648 1649 # Determine the number of columns required. Where participants provide 1650 # no columns for events, one still needs to be provided for the 1651 # participant itself. 1652 1653 all_columns = sum([max(columns, 1) for columns in group_columns]) 1654 1655 # Determine the days providing time slots. 1656 1657 all_days = days.items() 1658 all_days.sort() 1659 1660 # Produce a heading and time points for each day. 1661 1662 for day, intervals in all_days: 1663 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1664 is_empty = True 1665 1666 for slots in groups_for_day: 1667 if not slots: 1668 continue 1669 1670 for active in slots.values(): 1671 if active: 1672 is_empty = False 1673 break 1674 1675 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1676 page.tr() 1677 page.th(class_="dayheading container", colspan=all_columns+1) 1678 self._day_heading(day) 1679 page.th.close() 1680 page.tr.close() 1681 page.thead.close() 1682 1683 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1684 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1685 page.tbody.close() 1686 1687 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1688 1689 """ 1690 Show the time 'intervals' along with period information from the given 1691 'groups', having the indicated 'group_types', each with the number of 1692 columns given by 'group_columns'. 1693 """ 1694 1695 page = self.page 1696 1697 # Obtain the user's timezone. 1698 1699 tzid = self.get_tzid() 1700 1701 # Produce a row for each interval. 1702 1703 intervals = list(intervals) 1704 intervals.sort() 1705 1706 for point, endpoint in intervals: 1707 continuation = point == get_start_of_day(point, tzid) 1708 1709 # Some rows contain no period details and are marked as such. 1710 1711 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1712 1713 css = " ".join([ 1714 "slot", 1715 have_active and "busy" or "empty", 1716 continuation and "daystart" or "" 1717 ]) 1718 1719 page.tr(class_=css) 1720 page.th(class_="timeslot") 1721 self._time_point(point, endpoint) 1722 page.th.close() 1723 1724 # Obtain slots for the time point from each group. 1725 1726 for columns, slots, group_type in zip(group_columns, groups, group_types): 1727 active = slots and slots.get(point) 1728 1729 # Where no periods exist for the given time interval, generate 1730 # an empty cell. Where a participant provides no periods at all, 1731 # the colspan is adjusted to be 1, not 0. 1732 1733 if not active: 1734 page.td(class_="empty container", colspan=max(columns, 1)) 1735 self._empty_slot(point, endpoint) 1736 page.td.close() 1737 continue 1738 1739 slots = slots.items() 1740 slots.sort() 1741 spans = get_spans(slots) 1742 1743 empty = 0 1744 1745 # Show a column for each active period. 1746 1747 for t in active: 1748 if t and len(t) >= 2: 1749 1750 # Flush empty slots preceding this one. 1751 1752 if empty: 1753 page.td(class_="empty container", colspan=empty) 1754 self._empty_slot(point, endpoint) 1755 page.td.close() 1756 empty = 0 1757 1758 start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) 1759 span = spans[key] 1760 1761 # Produce a table cell only at the start of the period 1762 # or when continued at the start of a day. 1763 1764 if point == start or continuation: 1765 1766 has_continued = continuation and point != start 1767 will_continue = not ends_on_same_day(point, end, tzid) 1768 is_organiser = organiser == self.user 1769 1770 css = " ".join([ 1771 "event", 1772 has_continued and "continued" or "", 1773 will_continue and "continues" or "", 1774 is_organiser and "organising" or "attending" 1775 ]) 1776 1777 # Only anchor the first cell of events. 1778 # NOTE: Need to only anchor the first period for a 1779 # NOTE: recurring event. 1780 1781 html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") 1782 1783 if point == start and html_id not in self.html_ids: 1784 page.td(class_=css, rowspan=span, id=html_id) 1785 self.html_ids.add(html_id) 1786 else: 1787 page.td(class_=css, rowspan=span) 1788 1789 # Only link to events if they are not being 1790 # updated by requests. 1791 1792 if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": 1793 page.span(summary or "(Participant is busy)") 1794 else: 1795 page.a(summary, href=self.link_to(uid, recurrenceid)) 1796 1797 page.td.close() 1798 else: 1799 empty += 1 1800 1801 # Pad with empty columns. 1802 1803 empty = columns - len(active) 1804 1805 if empty: 1806 page.td(class_="empty container", colspan=empty) 1807 self._empty_slot(point, endpoint) 1808 page.td.close() 1809 1810 page.tr.close() 1811 1812 def _day_heading(self, day): 1813 1814 """ 1815 Generate a heading for 'day' of the following form: 1816 1817 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1818 """ 1819 1820 page = self.page 1821 daystr = format_datetime(day) 1822 value, identifier = self._day_value_and_identifier(day) 1823 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1824 1825 def _time_point(self, point, endpoint): 1826 1827 """ 1828 Generate headings for the 'point' to 'endpoint' period of the following 1829 form: 1830 1831 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1832 <span class="endpoint">10:00:00 CET</span> 1833 """ 1834 1835 page = self.page 1836 tzid = self.get_tzid() 1837 daystr = format_datetime(point.date()) 1838 value, identifier = self._slot_value_and_identifier(point, endpoint) 1839 slots = self.env.get_args().get("slot", []) 1840 self._slot_selector(value, identifier, slots) 1841 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1842 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1843 1844 def _slot_selector(self, value, identifier, slots): 1845 1846 """ 1847 Provide a timeslot control having the given 'value', employing the 1848 indicated HTML 'identifier', and using the given 'slots' collection 1849 to select any control whose 'value' is in this collection, unless the 1850 "reset" request parameter has been asserted. 1851 """ 1852 1853 reset = self.env.get_args().has_key("reset") 1854 page = self.page 1855 if not reset and value in slots: 1856 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1857 else: 1858 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1859 1860 def _empty_slot(self, point, endpoint): 1861 1862 "Show an empty slot label for the given 'point' and 'endpoint'." 1863 1864 page = self.page 1865 value, identifier = self._slot_value_and_identifier(point, endpoint) 1866 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1867 1868 def _day_value_and_identifier(self, day): 1869 1870 "Return a day value and HTML identifier for the given 'day'." 1871 1872 value = "%s-" % format_datetime(day) 1873 identifier = "day-%s" % value 1874 return value, identifier 1875 1876 def _slot_value_and_identifier(self, point, endpoint): 1877 1878 """ 1879 Return a slot value and HTML identifier for the given 'point' and 1880 'endpoint'. 1881 """ 1882 1883 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1884 identifier = "slot-%s" % value 1885 return value, identifier 1886 1887 def _show_menu(self, name, default, items, class_=""): 1888 1889 """ 1890 Show a select menu having the given 'name', set to the given 'default', 1891 providing the given (value, label) 'items', and employing the given CSS 1892 'class_' if specified. 1893 """ 1894 1895 page = self.page 1896 values = self.env.get_args().get(name, [default]) 1897 page.select(name=name, class_=class_) 1898 for v, label in items: 1899 if v is None: 1900 continue 1901 if v in values: 1902 page.option(label, value=v, selected="selected") 1903 else: 1904 page.option(label, value=v) 1905 page.select.close() 1906 1907 def _show_date_controls(self, name, default, attr): 1908 1909 """ 1910 Show date controls for a field with the given 'name' and 'default' value 1911 and 'attr'. 1912 """ 1913 1914 page = self.page 1915 args = self.env.get_args() 1916 1917 event_tzid = attr.get("TZID", self.get_tzid()) 1918 dt = get_datetime(default, attr) 1919 1920 # Show dates for up to one week around the current date. 1921 1922 base = get_date(dt) 1923 items = [] 1924 for i in range(-7, 8): 1925 d = base + timedelta(i) 1926 items.append((format_datetime(d), self.format_date(d, "full"))) 1927 1928 self._show_menu("%s-date" % name, format_datetime(base), items) 1929 1930 # Show time details. 1931 1932 dt_time = isinstance(dt, datetime) and dt or None 1933 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1934 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1935 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1936 1937 page.span(class_="time enabled") 1938 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1939 page.add(":") 1940 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1941 page.add(":") 1942 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1943 page.add(" ") 1944 self._show_timezone_menu("%s-tzid" % name, event_tzid) 1945 page.span.close() 1946 1947 def _show_timezone_menu(self, name, default): 1948 1949 """ 1950 Show timezone controls using a menu with the given 'name', set to the 1951 given 'default' unless a field of the given 'name' provides a value. 1952 """ 1953 1954 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1955 self._show_menu(name, default, entries) 1956 1957 # Incoming HTTP request direction. 1958 1959 def select_action(self): 1960 1961 "Select the desired action and show the result." 1962 1963 path_info = self.env.get_path_info().strip("/") 1964 1965 if not path_info: 1966 self.show_calendar() 1967 elif self.show_object(path_info): 1968 pass 1969 else: 1970 self.no_page() 1971 1972 def __call__(self): 1973 1974 "Interpret a request and show an appropriate response." 1975 1976 if not self.user: 1977 self.no_user() 1978 else: 1979 self.select_action() 1980 1981 # Write the headers and actual content. 1982 1983 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1984 print >>self.out 1985 self.out.write(unicode(self.page).encode(self.encoding)) 1986 1987 if __name__ == "__main__": 1988 Manager()() 1989 1990 # vim: tabstop=4 expandtab shiftwidth=4