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 639 have_action = reply or discard or invite or cancel or save 640 641 if not have_action: 642 return ["action"] 643 644 # Update the object. 645 646 if args.has_key("summary"): 647 obj["SUMMARY"] = [(args["summary"][0], {})] 648 649 attendees = uri_dict(obj.get_value_map("ATTENDEE")) 650 651 if args.has_key("partstat"): 652 if attendees.has_key(self.user): 653 attendees[self.user]["PARTSTAT"] = args["partstat"][0] 654 if attendees[self.user].has_key("RSVP"): 655 del attendees[self.user]["RSVP"] 656 657 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 658 659 # Obtain the user's timezone and process datetime values. 660 661 update = False 662 663 if is_organiser: 664 dtend_enabled = args.get("dtend-control", [None])[0] == "enable" 665 dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable" 666 667 t = self.handle_date_controls("dtstart", dttimes_enabled) 668 if t: 669 dtstart, attr = t 670 update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update 671 else: 672 return ["dtstart"] 673 674 # Handle specified end datetimes. 675 676 if dtend_enabled: 677 t = self.handle_date_controls("dtend", dttimes_enabled) 678 if t: 679 dtend, attr = t 680 681 # Convert end dates to iCalendar "next day" dates. 682 683 if not isinstance(dtend, datetime): 684 dtend += timedelta(1) 685 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 686 else: 687 return ["dtend"] 688 689 # Otherwise, treat the end date as the start date. Datetimes are 690 # handled by making the event occupy the rest of the day. 691 692 else: 693 dtend = dtstart + timedelta(1) 694 if isinstance(dtstart, datetime): 695 dtend = get_start_of_day(dtend, attr["TZID"]) 696 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 697 698 if dtstart >= dtend: 699 return ["dtstart", "dtend"] 700 701 # Obtain any participants to be added or removed. 702 703 removed = args.get("remove") 704 added = args.get("added") 705 706 # Process any action. 707 708 handled = True 709 710 if reply or invite or cancel: 711 712 handler = ManagerHandler(obj, self.user, self.messenger) 713 714 # Process the object and remove it from the list of requests. 715 716 if reply and handler.process_received_request(update) or \ 717 is_organiser and (invite or cancel) and \ 718 handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added): 719 720 self.remove_request(uid, recurrenceid) 721 722 # Save single user events. 723 724 elif save: 725 to_cancel = self.update_attendees(obj, added, removed) 726 self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node()) 727 self.update_freebusy(uid, recurrenceid, obj) 728 self.remove_request(uid, recurrenceid) 729 730 # Remove the request and the object. 731 732 elif discard: 733 self.remove_from_freebusy(uid, recurrenceid) 734 self.remove_event(uid, recurrenceid) 735 self.remove_request(uid, recurrenceid) 736 737 else: 738 handled = False 739 740 # Upon handling an action, redirect to the main page. 741 742 if handled: 743 self.redirect(self.env.get_path()) 744 745 return None 746 747 def handle_date_controls(self, name, with_time=True): 748 749 """ 750 Handle date control information for fields starting with 'name', 751 returning a (datetime, attr) tuple or None if the fields cannot be used 752 to construct a datetime object. 753 """ 754 755 args = self.env.get_args() 756 757 if args.has_key("%s-date" % name): 758 date = args["%s-date" % name][0] 759 760 if with_time: 761 hour = args.get("%s-hour" % name, [None])[0] 762 minute = args.get("%s-minute" % name, [None])[0] 763 second = args.get("%s-second" % name, [None])[0] 764 tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0] 765 766 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or "" 767 value = "%s%s" % (date, time) 768 attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"} 769 dt = get_datetime(value, attr) 770 else: 771 attr = {"VALUE" : "DATE"} 772 dt = get_datetime(date) 773 774 if dt: 775 return dt, attr 776 777 return None 778 779 def set_datetime_in_object(self, dt, tzid, property, obj): 780 781 """ 782 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 783 an update has occurred. 784 """ 785 786 if dt: 787 old_value = obj.get_value(property) 788 obj[property] = [get_datetime_item(dt, tzid)] 789 return format_datetime(dt) != old_value 790 791 return False 792 793 # Page fragment methods. 794 795 def show_request_controls(self, obj): 796 797 "Show form controls for a request concerning 'obj'." 798 799 page = self.page 800 args = self.env.get_args() 801 802 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 803 804 attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", [])) 805 is_attendee = self.user in attendees 806 807 is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() 808 809 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 810 811 # Show appropriate options depending on the role of the user. 812 813 if is_attendee and not is_organiser: 814 page.p("An action is required for this request:") 815 816 page.p() 817 page.input(name="reply", type="submit", value="Reply") 818 page.add(" ") 819 page.input(name="discard", type="submit", value="Discard") 820 page.p.close() 821 822 if is_organiser: 823 page.p("As organiser, you can perform the following:") 824 825 if have_other_attendees: 826 page.p() 827 page.input(name="invite", type="submit", value="Invite") 828 page.add(" ") 829 if is_request: 830 page.input(name="discard", type="submit", value="Discard") 831 else: 832 page.input(name="cancel", type="submit", value="Cancel") 833 page.p.close() 834 else: 835 page.p() 836 page.input(name="save", type="submit", value="Save") 837 page.add(" ") 838 page.input(name="discard", type="submit", value="Discard") 839 page.p.close() 840 841 property_items = [ 842 ("SUMMARY", "Summary"), 843 ("DTSTART", "Start"), 844 ("DTEND", "End"), 845 ("ORGANIZER", "Organiser"), 846 ("ATTENDEE", "Attendee"), 847 ] 848 849 partstat_items = [ 850 ("NEEDS-ACTION", "Not confirmed"), 851 ("ACCEPTED", "Attending"), 852 ("TENTATIVE", "Tentatively attending"), 853 ("DECLINED", "Not attending"), 854 ("DELEGATED", "Delegated"), 855 (None, "Not indicated"), 856 ] 857 858 def show_object_on_page(self, uid, obj, error=None): 859 860 """ 861 Show the calendar object with the given 'uid' and representation 'obj' 862 on the current page. If 'error' is given, show a suitable message. 863 """ 864 865 page = self.page 866 page.form(method="POST") 867 868 args = self.env.get_args() 869 870 # Obtain the user's timezone. 871 872 tzid = self.get_tzid() 873 874 # Obtain basic event information, showing any necessary editing controls. 875 876 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 877 878 if is_organiser: 879 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.show_object_organiser_controls(obj) 880 new_attendees, new_attendee = self.handle_new_attendees(obj) 881 else: 882 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj) 883 new_attendees = [] 884 new_attendee = "" 885 886 # Provide a summary of the object. 887 888 page.table(class_="object", cellspacing=5, cellpadding=5) 889 page.thead() 890 page.tr() 891 page.th("Event", class_="mainheading", colspan=2) 892 page.tr.close() 893 page.thead.close() 894 page.tbody() 895 896 for name, label in self.property_items: 897 page.tr() 898 899 # Handle datetimes specially. 900 901 if name in ["DTSTART", "DTEND"]: 902 field = name.lower() 903 904 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or "")) 905 906 # Obtain the datetime. 907 908 if name == "DTSTART": 909 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 910 911 # Where no end datetime exists, use the start datetime as the 912 # basis of any potential datetime specified if dt-control is 913 # set. 914 915 else: 916 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 917 918 # Show controls for editing as organiser. 919 920 if is_organiser: 921 value = format_datetime(dt) 922 923 page.td(class_="objectvalue %s" % field) 924 if name == "DTEND": 925 page.div(class_="dt disabled") 926 page.label("Specify end date", for_="dtend-enable", class_="enable") 927 page.div.close() 928 929 page.div(class_="dt enabled") 930 self._show_date_controls(field, value, attr, tzid) 931 if name == "DTSTART": 932 page.br() 933 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 934 page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") 935 elif name == "DTEND": 936 page.br() 937 page.label("End on same day", for_="dtend-disable", class_="disable") 938 page.div.close() 939 940 page.td.close() 941 942 # Show a label as attendee. 943 944 else: 945 page.td(self.format_datetime(dt, "full")) 946 947 page.tr.close() 948 949 # Handle the summary specially. 950 951 elif name == "SUMMARY": 952 value = args.get("summary", [obj.get_value(name)])[0] 953 954 page.th(label, class_="objectheading") 955 page.td() 956 if is_organiser: 957 page.input(name="summary", type="text", value=value, size=80) 958 else: 959 page.add(value) 960 page.td.close() 961 page.tr.close() 962 963 # Handle potentially many values. 964 965 else: 966 items = obj.get_items(name) or [] 967 rowspan = len(items) 968 969 if name == "ATTENDEE": 970 rowspan += len(new_attendees) + 1 971 elif not items: 972 continue 973 974 page.th(label, class_="objectheading", rowspan=rowspan) 975 976 first = True 977 978 for i, (value, attr) in enumerate(items): 979 if not first: 980 page.tr() 981 else: 982 first = False 983 984 if name == "ATTENDEE": 985 value = get_uri(value) 986 987 page.td(class_="objectvalue") 988 page.add(value) 989 page.add(" ") 990 991 partstat = attr.get("PARTSTAT") 992 if value == self.user: 993 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 994 else: 995 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 996 997 if is_organiser: 998 if value in args.get("remove", []): 999 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") 1000 else: 1001 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") 1002 page.label("Remove", for_="remove-%d" % i, class_="remove") 1003 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 1004 1005 else: 1006 page.td(class_="objectvalue") 1007 page.add(value) 1008 1009 page.td.close() 1010 page.tr.close() 1011 1012 # Allow more attendees to be specified. 1013 1014 if is_organiser and name == "ATTENDEE": 1015 for i, attendee in enumerate(new_attendees): 1016 if not first: 1017 page.tr() 1018 else: 1019 first = False 1020 1021 page.td() 1022 page.input(name="added", type="value", value=attendee) 1023 page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove") 1024 page.label("Remove", for_="removenew-%d" % i, class_="remove") 1025 page.td.close() 1026 page.tr.close() 1027 1028 if not first: 1029 page.tr() 1030 1031 page.td() 1032 page.input(name="attendee", type="value", value=new_attendee) 1033 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 1034 page.label("Add", for_="add-%d" % i, class_="add") 1035 page.td.close() 1036 page.tr.close() 1037 1038 page.tbody.close() 1039 page.table.close() 1040 1041 self.show_recurrences(obj) 1042 self.show_conflicting_events(uid, obj) 1043 self.show_request_controls(obj) 1044 1045 page.form.close() 1046 1047 def get_event_period(self, obj): 1048 1049 """ 1050 Return (dtstart, dtstart attributes), (dtend, dtend attributes) for 1051 'obj'. 1052 """ 1053 1054 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 1055 if obj.has_key("DTEND"): 1056 dtend, dtend_attr = obj.get_datetime_item("DTEND") 1057 elif obj.has_key("DURATION"): 1058 duration = obj.get_duration("DURATION") 1059 dtend = dtstart + duration 1060 dtend_attr = dtstart_attr 1061 else: 1062 dtend, dtend_attr = dtstart, dtstart_attr 1063 return (dtstart, dtstart_attr), (dtend, dtend_attr) 1064 1065 def handle_new_attendees(self, obj): 1066 1067 "Add or remove new attendees. This does not affect the stored object." 1068 1069 args = self.env.get_args() 1070 1071 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 1072 new_attendees = args.get("added", []) 1073 new_attendee = args.get("attendee", [""])[0] 1074 1075 if args.has_key("add"): 1076 if new_attendee.strip(): 1077 new_attendee = get_uri(new_attendee.strip()) 1078 if new_attendee not in new_attendees and new_attendee not in existing_attendees: 1079 new_attendees.append(new_attendee) 1080 new_attendee = "" 1081 1082 if args.has_key("removenew"): 1083 removed_attendee = args["removenew"][0] 1084 if removed_attendee in new_attendees: 1085 new_attendees.remove(removed_attendee) 1086 1087 return new_attendees, new_attendee 1088 1089 def show_object_organiser_controls(self, obj): 1090 1091 "Provide controls to change the displayed object 'obj'." 1092 1093 page = self.page 1094 args = self.env.get_args() 1095 1096 # Configure the start and end datetimes. 1097 1098 dtend_control = args.get("dtend-control", [None])[0] 1099 dttimes_control = args.get("dttimes-control", [None])[0] 1100 with_time = dttimes_control == "enable" 1101 1102 # Start with the object's original details, overriding them with request 1103 # information. 1104 1105 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj) 1106 1107 t = self.handle_date_controls("dtstart", with_time) 1108 if t: 1109 dtstart, dtstart_attr = t 1110 1111 if dtend_control == "enable": 1112 t = self.handle_date_controls("dtend", with_time) 1113 if t: 1114 dtend, dtend_attr = t 1115 else: 1116 dtend, dtend_attr = None, {} 1117 elif dtend_control == "disable": 1118 dtend, dtend_attr = None, {} 1119 1120 # Change end dates to refer to the actual dates, not the iCalendar 1121 # "next day" dates. 1122 1123 if dtend and not isinstance(dtend, datetime): 1124 dtend -= timedelta(1) 1125 1126 # Show the end datetime controls if already active or if an object needs 1127 # them. 1128 1129 dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend 1130 dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) 1131 1132 if dtend_enabled: 1133 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 1134 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 1135 else: 1136 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 1137 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 1138 1139 if dttimes_enabled: 1140 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") 1141 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") 1142 else: 1143 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") 1144 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") 1145 1146 return (dtstart, dtstart_attr), (dtend, dtend_attr) 1147 1148 def show_recurrences(self, obj): 1149 1150 "Show recurrences for the object having the given representation 'obj'." 1151 1152 page = self.page 1153 1154 # Obtain any parent object if this object is a specific recurrence. 1155 1156 uid = obj.get_value("UID") 1157 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 1158 1159 if recurrenceid: 1160 obj = self._get_object(uid) 1161 if not obj: 1162 return 1163 1164 page.p("This event modifies a recurring event.") 1165 1166 # Obtain the periods associated with the event in the user's time zone. 1167 1168 periods = obj.get_periods(self.get_tzid(), self.get_window_end()) 1169 recurrenceids = self._get_recurrences(uid) 1170 1171 if len(periods) == 1: 1172 return 1173 1174 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 1175 1176 page.table(cellspacing=5, cellpadding=5, class_="recurrences") 1177 page.thead() 1178 page.tr() 1179 page.th("Start") 1180 page.th("End") 1181 page.tr.close() 1182 page.thead.close() 1183 page.tbody() 1184 1185 for start, end in periods: 1186 start_utc = format_datetime(to_timezone(start, "UTC")) 1187 css = " ".join([ 1188 recurrenceids and start_utc in recurrenceids and "replaced" or "", 1189 recurrenceid and start_utc == recurrenceid and "affected" or "" 1190 ]) 1191 1192 page.tr() 1193 page.td(self.format_datetime(start, "long"), class_=css) 1194 page.td(self.format_datetime(end, "long"), class_=css) 1195 page.tr.close() 1196 1197 page.tbody.close() 1198 page.table.close() 1199 1200 def show_conflicting_events(self, uid, obj): 1201 1202 """ 1203 Show conflicting events for the object having the given 'uid' and 1204 representation 'obj'. 1205 """ 1206 1207 page = self.page 1208 1209 # Obtain the user's timezone. 1210 1211 tzid = self.get_tzid() 1212 1213 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 1214 if obj.has_key("DTEND"): 1215 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 1216 elif obj.has_key("DURATION"): 1217 duration = obj.get_duration("DURATION") 1218 dtend = format_datetime(obj.get_utc_datetime("DTSTART") + duration) 1219 else: 1220 dtend = dtstart 1221 1222 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 1223 1224 # Indicate whether there are conflicting events. 1225 1226 freebusy = self.store.get_freebusy(self.user) 1227 1228 if freebusy: 1229 1230 # Obtain any time zone details from the suggested event. 1231 1232 _dtstart, attr = obj.get_item("DTSTART") 1233 tzid = attr.get("TZID", tzid) 1234 1235 # Show any conflicts. 1236 1237 conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid] 1238 1239 if conflicts: 1240 page.p("This event conflicts with others:") 1241 1242 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1243 page.thead() 1244 page.tr() 1245 page.th("Event") 1246 page.th("Start") 1247 page.th("End") 1248 page.tr.close() 1249 page.thead.close() 1250 page.tbody() 1251 1252 for t in conflicts: 1253 start, end, found_uid, transp, found_recurrenceid = t[:5] 1254 1255 # Provide details of any conflicting event. 1256 1257 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 1258 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 1259 1260 page.tr() 1261 1262 # Show the event summary for the conflicting event. 1263 1264 page.td() 1265 1266 found_obj = self._get_object(found_uid, found_recurrenceid) 1267 if found_obj: 1268 page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid)) 1269 else: 1270 page.add("No details available") 1271 1272 page.td.close() 1273 1274 page.td(start) 1275 page.td(end) 1276 1277 page.tr.close() 1278 1279 page.tbody.close() 1280 page.table.close() 1281 1282 def show_requests_on_page(self): 1283 1284 "Show requests for the current user." 1285 1286 page = self.page 1287 1288 # NOTE: This list could be more informative, but it is envisaged that 1289 # NOTE: the requests would be visited directly anyway. 1290 1291 requests = self._get_requests() 1292 1293 page.div(id="pending-requests") 1294 1295 if requests: 1296 page.p("Pending requests:") 1297 1298 page.ul() 1299 1300 for uid, recurrenceid in requests: 1301 obj = self._get_object(uid, recurrenceid) 1302 if obj: 1303 page.li() 1304 page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 1305 page.li.close() 1306 1307 page.ul.close() 1308 1309 else: 1310 page.p("There are no pending requests.") 1311 1312 page.div.close() 1313 1314 def show_participants_on_page(self): 1315 1316 "Show participants for scheduling purposes." 1317 1318 page = self.page 1319 args = self.env.get_args() 1320 participants = args.get("participants", []) 1321 1322 try: 1323 for name, value in args.items(): 1324 if name.startswith("remove-participant-"): 1325 i = int(name[len("remove-participant-"):]) 1326 del participants[i] 1327 break 1328 except ValueError: 1329 pass 1330 1331 # Trim empty participants. 1332 1333 while participants and not participants[-1].strip(): 1334 participants.pop() 1335 1336 # Show any specified participants together with controls to remove and 1337 # add participants. 1338 1339 page.div(id="participants") 1340 1341 page.p("Participants for scheduling:") 1342 1343 for i, participant in enumerate(participants): 1344 page.p() 1345 page.input(name="participants", type="text", value=participant) 1346 page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1347 page.p.close() 1348 1349 page.p() 1350 page.input(name="participants", type="text") 1351 page.input(name="add-participant", type="submit", value="Add") 1352 page.p.close() 1353 1354 page.div.close() 1355 1356 return participants 1357 1358 # Full page output methods. 1359 1360 def show_object(self, path_info): 1361 1362 "Show an object request using the given 'path_info' for the current user." 1363 1364 uid, recurrenceid = self._get_identifiers(path_info) 1365 obj = self._get_object(uid, recurrenceid) 1366 1367 if not obj: 1368 return False 1369 1370 error = self.handle_request(uid, recurrenceid, obj) 1371 1372 if not error: 1373 return True 1374 1375 self.new_page(title="Event") 1376 self.show_object_on_page(uid, obj, error) 1377 1378 return True 1379 1380 def show_calendar(self): 1381 1382 "Show the calendar for the current user." 1383 1384 handled = self.handle_newevent() 1385 1386 self.new_page(title="Calendar") 1387 page = self.page 1388 1389 # Form controls are used in various places on the calendar page. 1390 1391 page.form(method="POST") 1392 1393 self.show_requests_on_page() 1394 participants = self.show_participants_on_page() 1395 1396 # Show a button for scheduling a new event. 1397 1398 page.p(class_="controls") 1399 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 1400 page.p.close() 1401 1402 # Show controls for hiding empty days and busy slots. 1403 # The positioning of the control, paragraph and table are important here. 1404 1405 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1406 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1407 1408 page.p(class_="controls") 1409 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1410 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1411 page.label("Show empty days", for_="showdays", class_="showdays disable") 1412 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1413 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1414 page.label("Clear selections", for_="reset", class_="reset") 1415 page.p.close() 1416 1417 freebusy = self.store.get_freebusy(self.user) 1418 1419 if not freebusy: 1420 page.p("No events scheduled.") 1421 return 1422 1423 # Obtain the user's timezone. 1424 1425 tzid = self.get_tzid() 1426 1427 # Day view: start at the earliest known day and produce days until the 1428 # latest known day, perhaps with expandable sections of empty days. 1429 1430 # Month view: start at the earliest known month and produce months until 1431 # the latest known month, perhaps with expandable sections of empty 1432 # months. 1433 1434 # Details of users to invite to new events could be superimposed on the 1435 # calendar. 1436 1437 # Requests are listed and linked to their tentative positions in the 1438 # calendar. Other participants are also shown. 1439 1440 request_summary = self._get_request_summary() 1441 1442 period_groups = [request_summary, freebusy] 1443 period_group_types = ["request", "freebusy"] 1444 period_group_sources = ["Pending requests", "Your schedule"] 1445 1446 for i, participant in enumerate(participants): 1447 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1448 period_group_types.append("freebusy-part%d" % i) 1449 period_group_sources.append(participant) 1450 1451 groups = [] 1452 group_columns = [] 1453 group_types = period_group_types 1454 group_sources = period_group_sources 1455 all_points = set() 1456 1457 # Obtain time point information for each group of periods. 1458 1459 for periods in period_groups: 1460 periods = convert_periods(periods, tzid) 1461 1462 # Get the time scale with start and end points. 1463 1464 scale = get_scale(periods) 1465 1466 # Get the time slots for the periods. 1467 1468 slots = get_slots(scale) 1469 1470 # Add start of day time points for multi-day periods. 1471 1472 add_day_start_points(slots, tzid) 1473 1474 # Record the slots and all time points employed. 1475 1476 groups.append(slots) 1477 all_points.update([point for point, active in slots]) 1478 1479 # Partition the groups into days. 1480 1481 days = {} 1482 partitioned_groups = [] 1483 partitioned_group_types = [] 1484 partitioned_group_sources = [] 1485 1486 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1487 1488 # Propagate time points to all groups of time slots. 1489 1490 add_slots(slots, all_points) 1491 1492 # Count the number of columns employed by the group. 1493 1494 columns = 0 1495 1496 # Partition the time slots by day. 1497 1498 partitioned = {} 1499 1500 for day, day_slots in partition_by_day(slots).items(): 1501 1502 # Construct a list of time intervals within the day. 1503 1504 intervals = [] 1505 last = None 1506 1507 for point, active in day_slots: 1508 columns = max(columns, len(active)) 1509 if last: 1510 intervals.append((last, point)) 1511 last = point 1512 1513 if last: 1514 intervals.append((last, None)) 1515 1516 if not days.has_key(day): 1517 days[day] = set() 1518 1519 # Convert each partition to a mapping from points to active 1520 # periods. 1521 1522 partitioned[day] = dict(day_slots) 1523 1524 # Record the divisions or intervals within each day. 1525 1526 days[day].update(intervals) 1527 1528 # Only include the requests column if it provides objects. 1529 1530 if group_type != "request" or columns: 1531 group_columns.append(columns) 1532 partitioned_groups.append(partitioned) 1533 partitioned_group_types.append(group_type) 1534 partitioned_group_sources.append(group_source) 1535 1536 # Add empty days. 1537 1538 add_empty_days(days, tzid) 1539 1540 # Show the controls permitting day selection. 1541 1542 self.show_calendar_day_controls(days) 1543 1544 # Show the calendar itself. 1545 1546 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1547 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1548 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1549 page.table.close() 1550 1551 # End the form region. 1552 1553 page.form.close() 1554 1555 # More page fragment methods. 1556 1557 def show_calendar_day_controls(self, days): 1558 1559 "Show controls for the given 'days' in the calendar." 1560 1561 page = self.page 1562 slots = self.env.get_args().get("slot", []) 1563 1564 for day in days: 1565 value, identifier = self._day_value_and_identifier(day) 1566 self._slot_selector(value, identifier, slots) 1567 1568 # Generate a dynamic stylesheet to allow day selections to colour 1569 # specific days. 1570 # NOTE: The style details need to be coordinated with the static 1571 # NOTE: stylesheet. 1572 1573 page.style(type="text/css") 1574 1575 for day in days: 1576 daystr = format_datetime(day) 1577 page.add("""\ 1578 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1579 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1580 background-color: #5f4; 1581 text-decoration: underline; 1582 } 1583 """ % (daystr, daystr, daystr, daystr)) 1584 1585 page.style.close() 1586 1587 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1588 1589 """ 1590 Show headings for the participants and other scheduling contributors, 1591 defined by 'group_types', 'group_sources' and 'group_columns'. 1592 """ 1593 1594 page = self.page 1595 1596 page.colgroup(span=1, id="columns-timeslot") 1597 1598 for group_type, columns in zip(group_types, group_columns): 1599 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1600 1601 page.thead() 1602 page.tr() 1603 page.th("", class_="emptyheading") 1604 1605 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1606 page.th(source, 1607 class_=(group_type == "request" and "requestheading" or "participantheading"), 1608 colspan=max(columns, 1)) 1609 1610 page.tr.close() 1611 page.thead.close() 1612 1613 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1614 1615 """ 1616 Show calendar days, defined by a collection of 'days', the contributing 1617 period information as 'partitioned_groups' (partitioned by day), the 1618 'partitioned_group_types' indicating the kind of contribution involved, 1619 and the 'group_columns' defining the number of columns in each group. 1620 """ 1621 1622 page = self.page 1623 1624 # Determine the number of columns required. Where participants provide 1625 # no columns for events, one still needs to be provided for the 1626 # participant itself. 1627 1628 all_columns = sum([max(columns, 1) for columns in group_columns]) 1629 1630 # Determine the days providing time slots. 1631 1632 all_days = days.items() 1633 all_days.sort() 1634 1635 # Produce a heading and time points for each day. 1636 1637 for day, intervals in all_days: 1638 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1639 is_empty = True 1640 1641 for slots in groups_for_day: 1642 if not slots: 1643 continue 1644 1645 for active in slots.values(): 1646 if active: 1647 is_empty = False 1648 break 1649 1650 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1651 page.tr() 1652 page.th(class_="dayheading container", colspan=all_columns+1) 1653 self._day_heading(day) 1654 page.th.close() 1655 page.tr.close() 1656 page.thead.close() 1657 1658 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1659 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1660 page.tbody.close() 1661 1662 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1663 1664 """ 1665 Show the time 'intervals' along with period information from the given 1666 'groups', having the indicated 'group_types', each with the number of 1667 columns given by 'group_columns'. 1668 """ 1669 1670 page = self.page 1671 1672 # Obtain the user's timezone. 1673 1674 tzid = self.get_tzid() 1675 1676 # Produce a row for each interval. 1677 1678 intervals = list(intervals) 1679 intervals.sort() 1680 1681 for point, endpoint in intervals: 1682 continuation = point == get_start_of_day(point, tzid) 1683 1684 # Some rows contain no period details and are marked as such. 1685 1686 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1687 1688 css = " ".join([ 1689 "slot", 1690 have_active and "busy" or "empty", 1691 continuation and "daystart" or "" 1692 ]) 1693 1694 page.tr(class_=css) 1695 page.th(class_="timeslot") 1696 self._time_point(point, endpoint) 1697 page.th.close() 1698 1699 # Obtain slots for the time point from each group. 1700 1701 for columns, slots, group_type in zip(group_columns, groups, group_types): 1702 active = slots and slots.get(point) 1703 1704 # Where no periods exist for the given time interval, generate 1705 # an empty cell. Where a participant provides no periods at all, 1706 # the colspan is adjusted to be 1, not 0. 1707 1708 if not active: 1709 page.td(class_="empty container", colspan=max(columns, 1)) 1710 self._empty_slot(point, endpoint) 1711 page.td.close() 1712 continue 1713 1714 slots = slots.items() 1715 slots.sort() 1716 spans = get_spans(slots) 1717 1718 empty = 0 1719 1720 # Show a column for each active period. 1721 1722 for t in active: 1723 if t and len(t) >= 2: 1724 1725 # Flush empty slots preceding this one. 1726 1727 if empty: 1728 page.td(class_="empty container", colspan=empty) 1729 self._empty_slot(point, endpoint) 1730 page.td.close() 1731 empty = 0 1732 1733 start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) 1734 span = spans[key] 1735 1736 # Produce a table cell only at the start of the period 1737 # or when continued at the start of a day. 1738 1739 if point == start or continuation: 1740 1741 has_continued = continuation and point != start 1742 will_continue = not ends_on_same_day(point, end, tzid) 1743 is_organiser = organiser == self.user 1744 1745 css = " ".join([ 1746 "event", 1747 has_continued and "continued" or "", 1748 will_continue and "continues" or "", 1749 is_organiser and "organising" or "attending" 1750 ]) 1751 1752 # Only anchor the first cell of events. 1753 # NOTE: Need to only anchor the first period for a 1754 # NOTE: recurring event. 1755 1756 html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") 1757 1758 if point == start and html_id not in self.html_ids: 1759 page.td(class_=css, rowspan=span, id=html_id) 1760 self.html_ids.add(html_id) 1761 else: 1762 page.td(class_=css, rowspan=span) 1763 1764 # Only link to events if they are not being 1765 # updated by requests. 1766 1767 if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": 1768 page.span(summary or "(Participant is busy)") 1769 else: 1770 page.a(summary, href=self.link_to(uid, recurrenceid)) 1771 1772 page.td.close() 1773 else: 1774 empty += 1 1775 1776 # Pad with empty columns. 1777 1778 empty = columns - len(active) 1779 1780 if empty: 1781 page.td(class_="empty container", colspan=empty) 1782 self._empty_slot(point, endpoint) 1783 page.td.close() 1784 1785 page.tr.close() 1786 1787 def _day_heading(self, day): 1788 1789 """ 1790 Generate a heading for 'day' of the following form: 1791 1792 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1793 """ 1794 1795 page = self.page 1796 daystr = format_datetime(day) 1797 value, identifier = self._day_value_and_identifier(day) 1798 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1799 1800 def _time_point(self, point, endpoint): 1801 1802 """ 1803 Generate headings for the 'point' to 'endpoint' period of the following 1804 form: 1805 1806 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1807 <span class="endpoint">10:00:00 CET</span> 1808 """ 1809 1810 page = self.page 1811 tzid = self.get_tzid() 1812 daystr = format_datetime(point.date()) 1813 value, identifier = self._slot_value_and_identifier(point, endpoint) 1814 slots = self.env.get_args().get("slot", []) 1815 self._slot_selector(value, identifier, slots) 1816 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1817 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1818 1819 def _slot_selector(self, value, identifier, slots): 1820 1821 """ 1822 Provide a timeslot control having the given 'value', employing the 1823 indicated HTML 'identifier', and using the given 'slots' collection 1824 to select any control whose 'value' is in this collection, unless the 1825 "reset" request parameter has been asserted. 1826 """ 1827 1828 reset = self.env.get_args().has_key("reset") 1829 page = self.page 1830 if not reset and value in slots: 1831 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1832 else: 1833 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1834 1835 def _empty_slot(self, point, endpoint): 1836 1837 "Show an empty slot label for the given 'point' and 'endpoint'." 1838 1839 page = self.page 1840 value, identifier = self._slot_value_and_identifier(point, endpoint) 1841 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1842 1843 def _day_value_and_identifier(self, day): 1844 1845 "Return a day value and HTML identifier for the given 'day'." 1846 1847 value = "%s-" % format_datetime(day) 1848 identifier = "day-%s" % value 1849 return value, identifier 1850 1851 def _slot_value_and_identifier(self, point, endpoint): 1852 1853 """ 1854 Return a slot value and HTML identifier for the given 'point' and 1855 'endpoint'. 1856 """ 1857 1858 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1859 identifier = "slot-%s" % value 1860 return value, identifier 1861 1862 def _show_menu(self, name, default, items, class_=""): 1863 1864 """ 1865 Show a select menu having the given 'name', set to the given 'default', 1866 providing the given (value, label) 'items', and employing the given CSS 1867 'class_' if specified. 1868 """ 1869 1870 page = self.page 1871 values = self.env.get_args().get(name, [default]) 1872 page.select(name=name, class_=class_) 1873 for v, label in items: 1874 if v is None: 1875 continue 1876 if v in values: 1877 page.option(label, value=v, selected="selected") 1878 else: 1879 page.option(label, value=v) 1880 page.select.close() 1881 1882 def _show_date_controls(self, name, default, attr, tzid): 1883 1884 """ 1885 Show date controls for a field with the given 'name' and 'default' value 1886 and 'attr', with the given 'tzid' being used if no other time regime 1887 information is provided. 1888 """ 1889 1890 page = self.page 1891 args = self.env.get_args() 1892 1893 event_tzid = attr.get("TZID", tzid) 1894 dt = get_datetime(default, attr) 1895 1896 # Show dates for up to one week around the current date. 1897 1898 base = get_date(dt) 1899 items = [] 1900 for i in range(-7, 8): 1901 d = base + timedelta(i) 1902 items.append((format_datetime(d), self.format_date(d, "full"))) 1903 1904 self._show_menu("%s-date" % name, format_datetime(base), items) 1905 1906 # Show time details. 1907 1908 dt_time = isinstance(dt, datetime) and dt or None 1909 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1910 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1911 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1912 1913 page.span(class_="time enabled") 1914 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1915 page.add(":") 1916 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1917 page.add(":") 1918 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1919 page.add(" ") 1920 self._show_timezone_menu("%s-tzid" % name, event_tzid) 1921 page.span.close() 1922 1923 def _show_timezone_menu(self, name, default): 1924 1925 """ 1926 Show timezone controls using a menu with the given 'name', set to the 1927 given 'default' unless a field of the given 'name' provides a value. 1928 """ 1929 1930 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 1931 self._show_menu(name, default, entries) 1932 1933 # Incoming HTTP request direction. 1934 1935 def select_action(self): 1936 1937 "Select the desired action and show the result." 1938 1939 path_info = self.env.get_path_info().strip("/") 1940 1941 if not path_info: 1942 self.show_calendar() 1943 elif self.show_object(path_info): 1944 pass 1945 else: 1946 self.no_page() 1947 1948 def __call__(self): 1949 1950 "Interpret a request and show an appropriate response." 1951 1952 if not self.user: 1953 self.no_user() 1954 else: 1955 self.select_action() 1956 1957 # Write the headers and actual content. 1958 1959 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1960 print >>self.out 1961 self.out.write(unicode(self.page).encode(self.encoding)) 1962 1963 if __name__ == "__main__": 1964 Manager()() 1965 1966 # vim: tabstop=4 expandtab shiftwidth=4