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