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