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