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 del obj["RDATE"] 820 821 for period in periods: 822 (dtstart, dtstart_attr), (dtend, dtend_attr) = period 823 tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") 824 new_rdates.append(get_period_item(dtstart, dtend, tzid)) 825 826 obj["RDATE"] = new_rdates 827 828 # NOTE: To do: calculate the update status. 829 return update 830 831 def set_datetime_in_object(self, dt, tzid, property, obj): 832 833 """ 834 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 835 an update has occurred. 836 """ 837 838 if dt: 839 old_value = obj.get_value(property) 840 obj[property] = [get_datetime_item(dt, tzid)] 841 return format_datetime(dt) != old_value 842 843 return False 844 845 def handle_new_attendees(self, obj): 846 847 "Add or remove new attendees. This does not affect the stored object." 848 849 args = self.env.get_args() 850 851 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 852 new_attendees = args.get("added", []) 853 new_attendee = args.get("attendee", [""])[0] 854 855 if args.has_key("add"): 856 if new_attendee.strip(): 857 new_attendee = get_uri(new_attendee.strip()) 858 if new_attendee not in new_attendees and new_attendee not in existing_attendees: 859 new_attendees.append(new_attendee) 860 new_attendee = "" 861 862 if args.has_key("removenew"): 863 removed_attendee = args["removenew"][0] 864 if removed_attendee in new_attendees: 865 new_attendees.remove(removed_attendee) 866 867 return new_attendees, new_attendee 868 869 def get_event_period(self, obj): 870 871 """ 872 Return (dtstart, dtstart attributes), (dtend, dtend attributes) for 873 'obj'. 874 """ 875 876 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 877 if obj.has_key("DTEND"): 878 dtend, dtend_attr = obj.get_datetime_item("DTEND") 879 elif obj.has_key("DURATION"): 880 duration = obj.get_duration("DURATION") 881 dtend = dtstart + duration 882 dtend_attr = dtstart_attr 883 else: 884 dtend, dtend_attr = dtstart, dtstart_attr 885 return (dtstart, dtstart_attr), (dtend, dtend_attr) 886 887 # Page fragment methods. 888 889 def show_request_controls(self, obj): 890 891 "Show form controls for a request concerning 'obj'." 892 893 page = self.page 894 args = self.env.get_args() 895 896 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 897 898 attendees = uri_values((obj.get_values("ATTENDEE") or []) + filter(None, args.get("attendee", []))) 899 is_attendee = self.user in attendees 900 901 is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() 902 903 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 904 905 # Show appropriate options depending on the role of the user. 906 907 if is_attendee and not is_organiser: 908 page.p("An action is required for this request:") 909 910 page.p() 911 page.input(name="reply", type="submit", value="Send reply") 912 page.add(" ") 913 page.input(name="discard", type="submit", value="Discard event") 914 page.add(" ") 915 page.input(name="ignore", type="submit", value="Do nothing for now") 916 page.p.close() 917 918 if is_organiser: 919 page.p("As organiser, you can perform the following:") 920 921 if have_other_attendees: 922 page.p() 923 page.input(name="invite", type="submit", value="Invite/notify attendees") 924 page.add(" ") 925 if is_request: 926 page.input(name="discard", type="submit", value="Discard event") 927 else: 928 page.input(name="cancel", type="submit", value="Cancel event") 929 page.add(" ") 930 page.input(name="ignore", type="submit", value="Do nothing for now") 931 page.p.close() 932 else: 933 page.p() 934 page.input(name="save", type="submit", value="Save event") 935 page.add(" ") 936 page.input(name="discard", type="submit", value="Discard event") 937 page.add(" ") 938 page.input(name="ignore", type="submit", value="Do nothing for now") 939 page.p.close() 940 941 property_items = [ 942 ("SUMMARY", "Summary"), 943 ("DTSTART", "Start"), 944 ("DTEND", "End"), 945 ("ORGANIZER", "Organiser"), 946 ("ATTENDEE", "Attendee"), 947 ] 948 949 partstat_items = [ 950 ("NEEDS-ACTION", "Not confirmed"), 951 ("ACCEPTED", "Attending"), 952 ("TENTATIVE", "Tentatively attending"), 953 ("DECLINED", "Not attending"), 954 ("DELEGATED", "Delegated"), 955 (None, "Not indicated"), 956 ] 957 958 def show_object_on_page(self, uid, obj, error=None): 959 960 """ 961 Show the calendar object with the given 'uid' and representation 'obj' 962 on the current page. If 'error' is given, show a suitable message. 963 """ 964 965 page = self.page 966 page.form(method="POST") 967 968 page.input(name="editing", type="hidden", value="true") 969 970 args = self.env.get_args() 971 972 # Obtain the user's timezone. 973 974 tzid = self.get_tzid() 975 976 # Obtain basic event information, showing any necessary editing controls. 977 978 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 979 980 if is_organiser: 981 new_attendees, new_attendee = self.handle_new_attendees(obj) 982 else: 983 new_attendees = [] 984 new_attendee = "" 985 986 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj) 987 self.show_object_datetime_controls(dtstart, dtend) 988 989 # Provide a summary of the object. 990 991 page.table(class_="object", cellspacing=5, cellpadding=5) 992 page.thead() 993 page.tr() 994 page.th("Event", class_="mainheading", colspan=2) 995 page.tr.close() 996 page.thead.close() 997 page.tbody() 998 999 for name, label in self.property_items: 1000 field = name.lower() 1001 1002 items = obj.get_items(name) or [] 1003 rowspan = len(items) 1004 1005 if name == "ATTENDEE": 1006 rowspan += len(new_attendees) + 1 1007 elif not items: 1008 continue 1009 1010 page.tr() 1011 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""), rowspan=rowspan) 1012 1013 # Handle datetimes specially. 1014 1015 if name in ["DTSTART", "DTEND"]: 1016 1017 # Obtain the datetime. 1018 1019 if name == "DTSTART": 1020 dt, attr = dtstart, dtstart_attr 1021 1022 # Where no end datetime exists, use the start datetime as the 1023 # basis of any potential datetime specified if dt-control is 1024 # set. 1025 1026 else: 1027 dt, attr = dtend or dtstart, dtend_attr or dtstart_attr 1028 1029 self.show_datetime_controls(obj, dt, attr, name == "DTSTART") 1030 1031 page.tr.close() 1032 1033 # Handle the summary specially. 1034 1035 elif name == "SUMMARY": 1036 value = args.get("summary", [obj.get_value(name)])[0] 1037 1038 page.td() 1039 if is_organiser: 1040 page.input(name="summary", type="text", value=value, size=80) 1041 else: 1042 page.add(value) 1043 page.td.close() 1044 page.tr.close() 1045 1046 # Handle potentially many values. 1047 1048 else: 1049 first = True 1050 1051 for i, (value, attr) in enumerate(items): 1052 if not first: 1053 page.tr() 1054 else: 1055 first = False 1056 1057 if name == "ATTENDEE": 1058 value = get_uri(value) 1059 1060 page.td(class_="objectvalue") 1061 page.add(value) 1062 page.add(" ") 1063 1064 partstat = attr.get("PARTSTAT") 1065 if value == self.user: 1066 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 1067 else: 1068 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 1069 1070 if is_organiser: 1071 if value in args.get("remove", []): 1072 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") 1073 else: 1074 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") 1075 page.label("Remove", for_="remove-%d" % i, class_="remove") 1076 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 1077 1078 else: 1079 page.td(class_="objectvalue") 1080 page.add(value) 1081 1082 page.td.close() 1083 page.tr.close() 1084 1085 # Allow more attendees to be specified. 1086 1087 if is_organiser and name == "ATTENDEE": 1088 for i, attendee in enumerate(new_attendees): 1089 if not first: 1090 page.tr() 1091 else: 1092 first = False 1093 1094 page.td() 1095 page.input(name="added", type="value", value=attendee) 1096 page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove") 1097 page.label("Remove", for_="removenew-%d" % i, class_="remove") 1098 page.td.close() 1099 page.tr.close() 1100 1101 if not first: 1102 page.tr() 1103 1104 page.td() 1105 page.input(name="attendee", type="value", value=new_attendee) 1106 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 1107 page.label("Add", for_="add-%d" % i, class_="add") 1108 page.td.close() 1109 page.tr.close() 1110 1111 page.tbody.close() 1112 page.table.close() 1113 1114 self.show_recurrences(obj) 1115 self.show_conflicting_events(uid, obj) 1116 self.show_request_controls(obj) 1117 1118 page.form.close() 1119 1120 def show_object_datetime_controls(self, start, end, index=None): 1121 1122 """ 1123 Show datetime-related controls if already active or if an object needs 1124 them for the given 'start' to 'end' period. The given 'index' is used to 1125 parameterise individual controls for dynamic manipulation. 1126 """ 1127 1128 page = self.page 1129 args = self.env.get_args() 1130 sn = self._suffixed_name 1131 ssn = self._simple_suffixed_name 1132 1133 # Add a dynamic stylesheet to permit the controls to modify the display. 1134 # NOTE: The style details need to be coordinated with the static 1135 # NOTE: stylesheet. 1136 1137 if index is not None: 1138 page.style(type="text/css") 1139 1140 # Unlike the rules for object properties, these affect recurrence 1141 # properties. 1142 1143 page.add("""\ 1144 input#dttimes-enable-%(index)d, 1145 input#dtend-enable-%(index)d, 1146 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 1147 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 1148 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 1149 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 1150 display: none; 1151 }""" % {"index" : index}) 1152 1153 page.style.close() 1154 1155 dtend_control = args.get(ssn("dtend-control", "recur", index), []) 1156 dttimes_control = args.get(ssn("dttimes-control", "recur", index), []) 1157 1158 dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control 1159 dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control 1160 1161 initial_load = not args.has_key("editing") 1162 1163 dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1)) 1164 dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime)) 1165 1166 if dtend_enabled: 1167 page.input(name=ssn("dtend-control", "recur", index), type="checkbox", 1168 value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index), checked="checked") 1169 else: 1170 page.input(name=ssn("dtend-control", "recur", index), type="checkbox", 1171 value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index)) 1172 1173 if dttimes_enabled: 1174 page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", 1175 value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index), checked="checked") 1176 else: 1177 page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", 1178 value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index)) 1179 1180 def show_datetime_controls(self, obj, dt, attr, show_start): 1181 1182 """ 1183 Show datetime details from the given 'obj' for the datetime 'dt' and 1184 attributes 'attr', showing start details if 'show_start' is set 1185 to a true value. Details will appear as controls for organisers and 1186 labels for attendees. 1187 """ 1188 1189 page = self.page 1190 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 1191 1192 # Change end dates to refer to the actual dates, not the iCalendar 1193 # "next day" dates. 1194 1195 if not show_start and not isinstance(dt, datetime): 1196 dt -= timedelta(1) 1197 1198 # Show controls for editing as organiser. 1199 1200 if is_organiser: 1201 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1202 1203 if show_start: 1204 page.div(class_="dt enabled") 1205 self._show_date_controls("dtstart", dt, attr.get("TZID")) 1206 page.br() 1207 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 1208 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 1209 page.div.close() 1210 1211 else: 1212 page.div(class_="dt disabled") 1213 page.label("Specify end date", for_="dtend-enable", class_="enable") 1214 page.div.close() 1215 page.div(class_="dt enabled") 1216 self._show_date_controls("dtend", dt, attr.get("TZID")) 1217 page.br() 1218 page.label("End on same day", for_="dtend-enable", class_="disable") 1219 page.div.close() 1220 1221 page.td.close() 1222 1223 # Show a label as attendee. 1224 1225 else: 1226 page.td(self.format_datetime(dt, "full")) 1227 1228 def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start): 1229 1230 """ 1231 Show datetime details from the given 'obj' for the recurrence having the 1232 given 'index', with the recurrence period described by the datetimes 1233 'start' and 'end', indicating the 'origin' of the period from the event 1234 details, employing any 'recurrenceid' and 'recurrenceids' for the object 1235 to configure the displayed information. 1236 1237 If 'show_start' is set to a true value, the start details will be shown; 1238 otherwise, the end details will be shown. 1239 """ 1240 1241 page = self.page 1242 sn = self._suffixed_name 1243 ssn = self._simple_suffixed_name 1244 1245 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 1246 1247 # Change end dates to refer to the actual dates, not the iCalendar 1248 # "next day" dates. 1249 1250 if not isinstance(end, datetime): 1251 end -= timedelta(1) 1252 1253 start_utc = format_datetime(to_timezone(start, "UTC")) 1254 replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" 1255 css = " ".join([ 1256 replaced, 1257 recurrenceid and start_utc == recurrenceid and "affected" or "" 1258 ]) 1259 1260 # Show controls for editing as organiser. 1261 1262 if is_organiser and not replaced and origin != "RRULE": 1263 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 1264 1265 if show_start: 1266 page.div(class_="dt enabled") 1267 self._show_date_controls(ssn("dtstart", "recur", index), start, None, index) 1268 page.br() 1269 page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") 1270 page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") 1271 page.div.close() 1272 1273 else: 1274 page.div(class_="dt disabled") 1275 page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") 1276 page.div.close() 1277 page.div(class_="dt enabled") 1278 self._show_date_controls(ssn("dtend", "recur", index), end, None, index) 1279 page.br() 1280 page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") 1281 page.div.close() 1282 1283 page.td.close() 1284 1285 # Show label as attendee. 1286 1287 else: 1288 page.td(self.format_datetime(show_start and start or end, "long"), class_=css) 1289 1290 def show_recurrences(self, obj): 1291 1292 "Show recurrences for the object having the given representation 'obj'." 1293 1294 page = self.page 1295 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 1296 1297 # Obtain any parent object if this object is a specific recurrence. 1298 1299 uid = obj.get_value("UID") 1300 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 1301 1302 if recurrenceid: 1303 obj = self._get_object(uid) 1304 if not obj: 1305 return 1306 1307 page.p("This event modifies a recurring event.") 1308 1309 # Obtain the periods associated with the event in the user's time zone. 1310 1311 periods = obj.get_periods(self.get_tzid(), self.get_window_end(), origin=True) 1312 recurrenceids = self._get_recurrences(uid) 1313 1314 if len(periods) == 1: 1315 return 1316 1317 if is_organiser: 1318 page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) 1319 else: 1320 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 1321 1322 # Determine whether any periods are explicitly created or are part of a 1323 # rule. 1324 1325 explicit_periods = filter(lambda t: t[2] != "RRULE", periods) 1326 1327 # Show each recurrence in a separate table if editable. 1328 1329 if is_organiser and explicit_periods: 1330 1331 for index, (start, end, origin) in enumerate(periods[1:]): 1332 1333 # Isolate the controls from neighbouring tables. 1334 1335 page.div() 1336 1337 self.show_object_datetime_controls(start, end, index) 1338 1339 # NOTE: Need to customise the TH classes according to errors and 1340 # NOTE: index information. 1341 1342 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 1343 page.caption("Occurrence") 1344 page.tbody() 1345 page.tr() 1346 page.th("Start", class_="objectheading start") 1347 self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) 1348 page.tr.close() 1349 page.tr() 1350 page.th("End", class_="objectheading end") 1351 self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) 1352 page.tr.close() 1353 page.tbody.close() 1354 page.table.close() 1355 1356 page.div.close() 1357 1358 # Otherwise, use a compact single table. 1359 1360 else: 1361 page.table(cellspacing=5, cellpadding=5, class_="recurrence") 1362 page.caption("Occurrences") 1363 page.thead() 1364 page.tr() 1365 page.th("Start", class_="objectheading start") 1366 page.th("End", class_="objectheading end") 1367 page.tr.close() 1368 page.thead.close() 1369 page.tbody() 1370 1371 # Show only subsequent periods if organiser, since the principal 1372 # period will be the start and end datetimes. 1373 1374 for index, (start, end, origin) in enumerate(is_organiser and periods[1:] or periods): 1375 page.tr() 1376 self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) 1377 self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) 1378 page.tr.close() 1379 page.tbody.close() 1380 page.table.close() 1381 1382 def show_conflicting_events(self, uid, obj): 1383 1384 """ 1385 Show conflicting events for the object having the given 'uid' and 1386 representation 'obj'. 1387 """ 1388 1389 page = self.page 1390 1391 # Obtain the user's timezone. 1392 1393 tzid = self.get_tzid() 1394 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 1395 1396 # Indicate whether there are conflicting events. 1397 1398 freebusy = self.store.get_freebusy(self.user) 1399 1400 if freebusy: 1401 1402 # Obtain any time zone details from the suggested event. 1403 1404 _dtstart, attr = obj.get_item("DTSTART") 1405 tzid = attr.get("TZID", tzid) 1406 1407 # Show any conflicts. 1408 1409 conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid] 1410 1411 if conflicts: 1412 page.p("This event conflicts with others:") 1413 1414 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1415 page.thead() 1416 page.tr() 1417 page.th("Event") 1418 page.th("Start") 1419 page.th("End") 1420 page.tr.close() 1421 page.thead.close() 1422 page.tbody() 1423 1424 for t in conflicts: 1425 start, end, found_uid, transp, found_recurrenceid, summary = t[:6] 1426 1427 # Provide details of any conflicting event. 1428 1429 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 1430 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 1431 1432 page.tr() 1433 1434 # Show the event summary for the conflicting event. 1435 1436 page.td() 1437 page.a(summary, href=self.link_to(found_uid)) 1438 page.td.close() 1439 1440 page.td(start) 1441 page.td(end) 1442 1443 page.tr.close() 1444 1445 page.tbody.close() 1446 page.table.close() 1447 1448 def show_requests_on_page(self): 1449 1450 "Show requests for the current user." 1451 1452 page = self.page 1453 1454 # NOTE: This list could be more informative, but it is envisaged that 1455 # NOTE: the requests would be visited directly anyway. 1456 1457 requests = self._get_requests() 1458 1459 page.div(id="pending-requests") 1460 1461 if requests: 1462 page.p("Pending requests:") 1463 1464 page.ul() 1465 1466 for uid, recurrenceid in requests: 1467 obj = self._get_object(uid, recurrenceid) 1468 if obj: 1469 page.li() 1470 page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 1471 page.li.close() 1472 1473 page.ul.close() 1474 1475 else: 1476 page.p("There are no pending requests.") 1477 1478 page.div.close() 1479 1480 def show_participants_on_page(self): 1481 1482 "Show participants for scheduling purposes." 1483 1484 page = self.page 1485 args = self.env.get_args() 1486 participants = args.get("participants", []) 1487 1488 try: 1489 for name, value in args.items(): 1490 if name.startswith("remove-participant-"): 1491 i = int(name[len("remove-participant-"):]) 1492 del participants[i] 1493 break 1494 except ValueError: 1495 pass 1496 1497 # Trim empty participants. 1498 1499 while participants and not participants[-1].strip(): 1500 participants.pop() 1501 1502 # Show any specified participants together with controls to remove and 1503 # add participants. 1504 1505 page.div(id="participants") 1506 1507 page.p("Participants for scheduling:") 1508 1509 for i, participant in enumerate(participants): 1510 page.p() 1511 page.input(name="participants", type="text", value=participant) 1512 page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1513 page.p.close() 1514 1515 page.p() 1516 page.input(name="participants", type="text") 1517 page.input(name="add-participant", type="submit", value="Add") 1518 page.p.close() 1519 1520 page.div.close() 1521 1522 return participants 1523 1524 # Full page output methods. 1525 1526 def show_object(self, path_info): 1527 1528 "Show an object request using the given 'path_info' for the current user." 1529 1530 uid, recurrenceid = self._get_identifiers(path_info) 1531 obj = self._get_object(uid, recurrenceid) 1532 1533 if not obj: 1534 return False 1535 1536 error = self.handle_request(uid, recurrenceid, obj) 1537 1538 if not error: 1539 return True 1540 1541 self.new_page(title="Event") 1542 self.show_object_on_page(uid, obj, error) 1543 1544 return True 1545 1546 def show_calendar(self): 1547 1548 "Show the calendar for the current user." 1549 1550 handled = self.handle_newevent() 1551 1552 self.new_page(title="Calendar") 1553 page = self.page 1554 1555 # Form controls are used in various places on the calendar page. 1556 1557 page.form(method="POST") 1558 1559 self.show_requests_on_page() 1560 participants = self.show_participants_on_page() 1561 1562 # Show a button for scheduling a new event. 1563 1564 page.p(class_="controls") 1565 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 1566 page.p.close() 1567 1568 # Show controls for hiding empty days and busy slots. 1569 # The positioning of the control, paragraph and table are important here. 1570 1571 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1572 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1573 1574 page.p(class_="controls") 1575 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1576 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1577 page.label("Show empty days", for_="showdays", class_="showdays disable") 1578 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1579 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1580 page.label("Clear selections", for_="reset", class_="reset") 1581 page.p.close() 1582 1583 freebusy = self.store.get_freebusy(self.user) 1584 1585 if not freebusy: 1586 page.p("No events scheduled.") 1587 return 1588 1589 # Obtain the user's timezone. 1590 1591 tzid = self.get_tzid() 1592 1593 # Day view: start at the earliest known day and produce days until the 1594 # latest known day, perhaps with expandable sections of empty days. 1595 1596 # Month view: start at the earliest known month and produce months until 1597 # the latest known month, perhaps with expandable sections of empty 1598 # months. 1599 1600 # Details of users to invite to new events could be superimposed on the 1601 # calendar. 1602 1603 # Requests are listed and linked to their tentative positions in the 1604 # calendar. Other participants are also shown. 1605 1606 request_summary = self._get_request_summary() 1607 1608 period_groups = [request_summary, freebusy] 1609 period_group_types = ["request", "freebusy"] 1610 period_group_sources = ["Pending requests", "Your schedule"] 1611 1612 for i, participant in enumerate(participants): 1613 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1614 period_group_types.append("freebusy-part%d" % i) 1615 period_group_sources.append(participant) 1616 1617 groups = [] 1618 group_columns = [] 1619 group_types = period_group_types 1620 group_sources = period_group_sources 1621 all_points = set() 1622 1623 # Obtain time point information for each group of periods. 1624 1625 for periods in period_groups: 1626 periods = convert_periods(periods, tzid) 1627 1628 # Get the time scale with start and end points. 1629 1630 scale = get_scale(periods) 1631 1632 # Get the time slots for the periods. 1633 1634 slots = get_slots(scale) 1635 1636 # Add start of day time points for multi-day periods. 1637 1638 add_day_start_points(slots, tzid) 1639 1640 # Record the slots and all time points employed. 1641 1642 groups.append(slots) 1643 all_points.update([point for point, active in slots]) 1644 1645 # Partition the groups into days. 1646 1647 days = {} 1648 partitioned_groups = [] 1649 partitioned_group_types = [] 1650 partitioned_group_sources = [] 1651 1652 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1653 1654 # Propagate time points to all groups of time slots. 1655 1656 add_slots(slots, all_points) 1657 1658 # Count the number of columns employed by the group. 1659 1660 columns = 0 1661 1662 # Partition the time slots by day. 1663 1664 partitioned = {} 1665 1666 for day, day_slots in partition_by_day(slots).items(): 1667 1668 # Construct a list of time intervals within the day. 1669 1670 intervals = [] 1671 last = None 1672 1673 for point, active in day_slots: 1674 columns = max(columns, len(active)) 1675 if last: 1676 intervals.append((last, point)) 1677 last = point 1678 1679 if last: 1680 intervals.append((last, None)) 1681 1682 if not days.has_key(day): 1683 days[day] = set() 1684 1685 # Convert each partition to a mapping from points to active 1686 # periods. 1687 1688 partitioned[day] = dict(day_slots) 1689 1690 # Record the divisions or intervals within each day. 1691 1692 days[day].update(intervals) 1693 1694 # Only include the requests column if it provides objects. 1695 1696 if group_type != "request" or columns: 1697 group_columns.append(columns) 1698 partitioned_groups.append(partitioned) 1699 partitioned_group_types.append(group_type) 1700 partitioned_group_sources.append(group_source) 1701 1702 # Add empty days. 1703 1704 add_empty_days(days, tzid) 1705 1706 # Show the controls permitting day selection. 1707 1708 self.show_calendar_day_controls(days) 1709 1710 # Show the calendar itself. 1711 1712 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1713 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1714 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1715 page.table.close() 1716 1717 # End the form region. 1718 1719 page.form.close() 1720 1721 # More page fragment methods. 1722 1723 def show_calendar_day_controls(self, days): 1724 1725 "Show controls for the given 'days' in the calendar." 1726 1727 page = self.page 1728 slots = self.env.get_args().get("slot", []) 1729 1730 for day in days: 1731 value, identifier = self._day_value_and_identifier(day) 1732 self._slot_selector(value, identifier, slots) 1733 1734 # Generate a dynamic stylesheet to allow day selections to colour 1735 # specific days. 1736 # NOTE: The style details need to be coordinated with the static 1737 # NOTE: stylesheet. 1738 1739 page.style(type="text/css") 1740 1741 for day in days: 1742 daystr = format_datetime(day) 1743 page.add("""\ 1744 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1745 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1746 background-color: #5f4; 1747 text-decoration: underline; 1748 } 1749 """ % (daystr, daystr, daystr, daystr)) 1750 1751 page.style.close() 1752 1753 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1754 1755 """ 1756 Show headings for the participants and other scheduling contributors, 1757 defined by 'group_types', 'group_sources' and 'group_columns'. 1758 """ 1759 1760 page = self.page 1761 1762 page.colgroup(span=1, id="columns-timeslot") 1763 1764 for group_type, columns in zip(group_types, group_columns): 1765 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1766 1767 page.thead() 1768 page.tr() 1769 page.th("", class_="emptyheading") 1770 1771 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1772 page.th(source, 1773 class_=(group_type == "request" and "requestheading" or "participantheading"), 1774 colspan=max(columns, 1)) 1775 1776 page.tr.close() 1777 page.thead.close() 1778 1779 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1780 1781 """ 1782 Show calendar days, defined by a collection of 'days', the contributing 1783 period information as 'partitioned_groups' (partitioned by day), the 1784 'partitioned_group_types' indicating the kind of contribution involved, 1785 and the 'group_columns' defining the number of columns in each group. 1786 """ 1787 1788 page = self.page 1789 1790 # Determine the number of columns required. Where participants provide 1791 # no columns for events, one still needs to be provided for the 1792 # participant itself. 1793 1794 all_columns = sum([max(columns, 1) for columns in group_columns]) 1795 1796 # Determine the days providing time slots. 1797 1798 all_days = days.items() 1799 all_days.sort() 1800 1801 # Produce a heading and time points for each day. 1802 1803 for day, intervals in all_days: 1804 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1805 is_empty = True 1806 1807 for slots in groups_for_day: 1808 if not slots: 1809 continue 1810 1811 for active in slots.values(): 1812 if active: 1813 is_empty = False 1814 break 1815 1816 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1817 page.tr() 1818 page.th(class_="dayheading container", colspan=all_columns+1) 1819 self._day_heading(day) 1820 page.th.close() 1821 page.tr.close() 1822 page.thead.close() 1823 1824 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1825 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1826 page.tbody.close() 1827 1828 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1829 1830 """ 1831 Show the time 'intervals' along with period information from the given 1832 'groups', having the indicated 'group_types', each with the number of 1833 columns given by 'group_columns'. 1834 """ 1835 1836 page = self.page 1837 1838 # Obtain the user's timezone. 1839 1840 tzid = self.get_tzid() 1841 1842 # Produce a row for each interval. 1843 1844 intervals = list(intervals) 1845 intervals.sort() 1846 1847 for point, endpoint in intervals: 1848 continuation = point == get_start_of_day(point, tzid) 1849 1850 # Some rows contain no period details and are marked as such. 1851 1852 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1853 1854 css = " ".join([ 1855 "slot", 1856 have_active and "busy" or "empty", 1857 continuation and "daystart" or "" 1858 ]) 1859 1860 page.tr(class_=css) 1861 page.th(class_="timeslot") 1862 self._time_point(point, endpoint) 1863 page.th.close() 1864 1865 # Obtain slots for the time point from each group. 1866 1867 for columns, slots, group_type in zip(group_columns, groups, group_types): 1868 active = slots and slots.get(point) 1869 1870 # Where no periods exist for the given time interval, generate 1871 # an empty cell. Where a participant provides no periods at all, 1872 # the colspan is adjusted to be 1, not 0. 1873 1874 if not active: 1875 page.td(class_="empty container", colspan=max(columns, 1)) 1876 self._empty_slot(point, endpoint) 1877 page.td.close() 1878 continue 1879 1880 slots = slots.items() 1881 slots.sort() 1882 spans = get_spans(slots) 1883 1884 empty = 0 1885 1886 # Show a column for each active period. 1887 1888 for t in active: 1889 if t and len(t) >= 2: 1890 1891 # Flush empty slots preceding this one. 1892 1893 if empty: 1894 page.td(class_="empty container", colspan=empty) 1895 self._empty_slot(point, endpoint) 1896 page.td.close() 1897 empty = 0 1898 1899 start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) 1900 span = spans[key] 1901 1902 # Produce a table cell only at the start of the period 1903 # or when continued at the start of a day. 1904 1905 if point == start or continuation: 1906 1907 has_continued = continuation and point != start 1908 will_continue = not ends_on_same_day(point, end, tzid) 1909 is_organiser = organiser == self.user 1910 1911 css = " ".join([ 1912 "event", 1913 has_continued and "continued" or "", 1914 will_continue and "continues" or "", 1915 is_organiser and "organising" or "attending" 1916 ]) 1917 1918 # Only anchor the first cell of events. 1919 # Need to only anchor the first period for a recurring 1920 # event. 1921 1922 html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") 1923 1924 if point == start and html_id not in self.html_ids: 1925 page.td(class_=css, rowspan=span, id=html_id) 1926 self.html_ids.add(html_id) 1927 else: 1928 page.td(class_=css, rowspan=span) 1929 1930 # Only link to events if they are not being 1931 # updated by requests. 1932 1933 if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": 1934 page.span(summary or "(Participant is busy)") 1935 else: 1936 page.a(summary, href=self.link_to(uid, recurrenceid)) 1937 1938 page.td.close() 1939 else: 1940 empty += 1 1941 1942 # Pad with empty columns. 1943 1944 empty = columns - len(active) 1945 1946 if empty: 1947 page.td(class_="empty container", colspan=empty) 1948 self._empty_slot(point, endpoint) 1949 page.td.close() 1950 1951 page.tr.close() 1952 1953 def _day_heading(self, day): 1954 1955 """ 1956 Generate a heading for 'day' of the following form: 1957 1958 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1959 """ 1960 1961 page = self.page 1962 daystr = format_datetime(day) 1963 value, identifier = self._day_value_and_identifier(day) 1964 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1965 1966 def _time_point(self, point, endpoint): 1967 1968 """ 1969 Generate headings for the 'point' to 'endpoint' period of the following 1970 form: 1971 1972 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1973 <span class="endpoint">10:00:00 CET</span> 1974 """ 1975 1976 page = self.page 1977 tzid = self.get_tzid() 1978 daystr = format_datetime(point.date()) 1979 value, identifier = self._slot_value_and_identifier(point, endpoint) 1980 slots = self.env.get_args().get("slot", []) 1981 self._slot_selector(value, identifier, slots) 1982 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1983 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1984 1985 def _slot_selector(self, value, identifier, slots): 1986 1987 """ 1988 Provide a timeslot control having the given 'value', employing the 1989 indicated HTML 'identifier', and using the given 'slots' collection 1990 to select any control whose 'value' is in this collection, unless the 1991 "reset" request parameter has been asserted. 1992 """ 1993 1994 reset = self.env.get_args().has_key("reset") 1995 page = self.page 1996 if not reset and value in slots: 1997 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1998 else: 1999 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 2000 2001 def _empty_slot(self, point, endpoint): 2002 2003 "Show an empty slot label for the given 'point' and 'endpoint'." 2004 2005 page = self.page 2006 value, identifier = self._slot_value_and_identifier(point, endpoint) 2007 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 2008 2009 def _day_value_and_identifier(self, day): 2010 2011 "Return a day value and HTML identifier for the given 'day'." 2012 2013 value = "%s-" % format_datetime(day) 2014 identifier = "day-%s" % value 2015 return value, identifier 2016 2017 def _slot_value_and_identifier(self, point, endpoint): 2018 2019 """ 2020 Return a slot value and HTML identifier for the given 'point' and 2021 'endpoint'. 2022 """ 2023 2024 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 2025 identifier = "slot-%s" % value 2026 return value, identifier 2027 2028 def _show_menu(self, name, default, items, class_="", index=None): 2029 2030 """ 2031 Show a select menu having the given 'name', set to the given 'default', 2032 providing the given (value, label) 'items', and employing the given CSS 2033 'class_' if specified. 2034 """ 2035 2036 page = self.page 2037 values = self.env.get_args().get(name, [default]) 2038 if index is not None: 2039 values = values[index:] 2040 values = values and values[0:1] or [default] 2041 2042 page.select(name=name, class_=class_) 2043 for v, label in items: 2044 if v is None: 2045 continue 2046 if v in values: 2047 page.option(label, value=v, selected="selected") 2048 else: 2049 page.option(label, value=v) 2050 page.select.close() 2051 2052 def _show_date_controls(self, name, default, tzid, index=None): 2053 2054 """ 2055 Show date controls for a field with the given 'name' and 'default' value 2056 and 'tzid'. 2057 """ 2058 2059 page = self.page 2060 args = self.env.get_args() 2061 2062 event_tzid = tzid or self.get_tzid() 2063 2064 # Show dates for up to one week around the current date. 2065 2066 base = to_date(default) 2067 items = [] 2068 for i in range(-7, 8): 2069 d = base + timedelta(i) 2070 items.append((format_datetime(d), self.format_date(d, "full"))) 2071 2072 self._show_menu("%s-date" % name, format_datetime(base), items, index=index) 2073 2074 # Show time details. 2075 2076 default_time = isinstance(default, datetime) and default or None 2077 2078 hour = args.get("%s-hour" % name, [])[index or 0:] 2079 hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0) 2080 minute = args.get("%s-minute" % name, [])[index or 0:] 2081 minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0) 2082 second = args.get("%s-second" % name, [])[index or 0:] 2083 second = second and second[0] or "%02d" % (default_time and default_time.second or 0) 2084 2085 page.span(class_="time enabled") 2086 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 2087 page.add(":") 2088 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 2089 page.add(":") 2090 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 2091 page.add(" ") 2092 self._show_timezone_menu("%s-tzid" % name, event_tzid, index) 2093 page.span.close() 2094 2095 def _show_timezone_menu(self, name, default, index=None): 2096 2097 """ 2098 Show timezone controls using a menu with the given 'name', set to the 2099 given 'default' unless a field of the given 'name' provides a value. 2100 """ 2101 2102 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 2103 self._show_menu(name, default, entries, index=index) 2104 2105 # Incoming HTTP request direction. 2106 2107 def select_action(self): 2108 2109 "Select the desired action and show the result." 2110 2111 path_info = self.env.get_path_info().strip("/") 2112 2113 if not path_info: 2114 self.show_calendar() 2115 elif self.show_object(path_info): 2116 pass 2117 else: 2118 self.no_page() 2119 2120 def __call__(self): 2121 2122 "Interpret a request and show an appropriate response." 2123 2124 if not self.user: 2125 self.no_user() 2126 else: 2127 self.select_action() 2128 2129 # Write the headers and actual content. 2130 2131 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 2132 print >>self.out 2133 self.out.write(unicode(self.page).encode(self.encoding)) 2134 2135 if __name__ == "__main__": 2136 Manager()() 2137 2138 # vim: tabstop=4 expandtab shiftwidth=4