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