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