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