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