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