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