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