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