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