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