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.p.close() 1391 1392 # Show controls for hiding empty days and busy slots. 1393 # The positioning of the control, paragraph and table are important here. 1394 1395 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1396 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1397 1398 page.p(class_="controls") 1399 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1400 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1401 page.label("Show empty days", for_="showdays", class_="showdays disable") 1402 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1403 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1404 page.label("Clear selections", for_="reset", class_="reset") 1405 page.p.close() 1406 1407 freebusy = self.store.get_freebusy(self.user) 1408 1409 if not freebusy: 1410 page.p("No events scheduled.") 1411 return 1412 1413 # Obtain the user's timezone. 1414 1415 tzid = self.get_tzid() 1416 1417 # Day view: start at the earliest known day and produce days until the 1418 # latest known day, perhaps with expandable sections of empty days. 1419 1420 # Month view: start at the earliest known month and produce months until 1421 # the latest known month, perhaps with expandable sections of empty 1422 # months. 1423 1424 # Details of users to invite to new events could be superimposed on the 1425 # calendar. 1426 1427 # Requests are listed and linked to their tentative positions in the 1428 # calendar. Other participants are also shown. 1429 1430 request_summary = self._get_request_summary() 1431 1432 period_groups = [request_summary, freebusy] 1433 period_group_types = ["request", "freebusy"] 1434 period_group_sources = ["Pending requests", "Your schedule"] 1435 1436 for i, participant in enumerate(participants): 1437 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1438 period_group_types.append("freebusy-part%d" % i) 1439 period_group_sources.append(participant) 1440 1441 groups = [] 1442 group_columns = [] 1443 group_types = period_group_types 1444 group_sources = period_group_sources 1445 all_points = set() 1446 1447 # Obtain time point information for each group of periods. 1448 1449 for periods in period_groups: 1450 periods = convert_periods(periods, tzid) 1451 1452 # Get the time scale with start and end points. 1453 1454 scale = get_scale(periods) 1455 1456 # Get the time slots for the periods. 1457 1458 slots = get_slots(scale) 1459 1460 # Add start of day time points for multi-day periods. 1461 1462 add_day_start_points(slots, tzid) 1463 1464 # Record the slots and all time points employed. 1465 1466 groups.append(slots) 1467 all_points.update([point for point, active in slots]) 1468 1469 # Partition the groups into days. 1470 1471 days = {} 1472 partitioned_groups = [] 1473 partitioned_group_types = [] 1474 partitioned_group_sources = [] 1475 1476 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1477 1478 # Propagate time points to all groups of time slots. 1479 1480 add_slots(slots, all_points) 1481 1482 # Count the number of columns employed by the group. 1483 1484 columns = 0 1485 1486 # Partition the time slots by day. 1487 1488 partitioned = {} 1489 1490 for day, day_slots in partition_by_day(slots).items(): 1491 intervals = [] 1492 last = None 1493 1494 for point, active in day_slots: 1495 columns = max(columns, len(active)) 1496 if last: 1497 intervals.append((last, point)) 1498 last = point 1499 1500 if last: 1501 intervals.append((last, None)) 1502 1503 if not days.has_key(day): 1504 days[day] = set() 1505 1506 # Convert each partition to a mapping from points to active 1507 # periods. 1508 1509 partitioned[day] = dict(day_slots) 1510 1511 # Record the divisions or intervals within each day. 1512 1513 days[day].update(intervals) 1514 1515 if group_type != "request" or columns: 1516 group_columns.append(columns) 1517 partitioned_groups.append(partitioned) 1518 partitioned_group_types.append(group_type) 1519 partitioned_group_sources.append(group_source) 1520 1521 # Add empty days. 1522 1523 add_empty_days(days, tzid) 1524 1525 # Show the controls permitting day selection. 1526 1527 self.show_calendar_day_controls(days) 1528 1529 # Show the calendar itself. 1530 1531 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1532 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1533 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1534 page.table.close() 1535 1536 # End the form region. 1537 1538 page.form.close() 1539 1540 # More page fragment methods. 1541 1542 def show_calendar_day_controls(self, days): 1543 1544 "Show controls for the given 'days' in the calendar." 1545 1546 page = self.page 1547 slots = self.env.get_args().get("slot", []) 1548 1549 for day in days: 1550 value, identifier = self._day_value_and_identifier(day) 1551 self._slot_selector(value, identifier, slots) 1552 1553 # Generate a dynamic stylesheet to allow day selections to colour 1554 # specific days. 1555 # NOTE: The style details need to be coordinated with the static 1556 # NOTE: stylesheet. 1557 1558 page.style(type="text/css") 1559 1560 for day in days: 1561 daystr = format_datetime(day) 1562 page.add("""\ 1563 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1564 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1565 background-color: #5f4; 1566 text-decoration: underline; 1567 } 1568 """ % (daystr, daystr, daystr, daystr)) 1569 1570 page.style.close() 1571 1572 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1573 1574 """ 1575 Show headings for the participants and other scheduling contributors, 1576 defined by 'group_types', 'group_sources' and 'group_columns'. 1577 """ 1578 1579 page = self.page 1580 1581 page.colgroup(span=1, id="columns-timeslot") 1582 1583 for group_type, columns in zip(group_types, group_columns): 1584 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1585 1586 page.thead() 1587 page.tr() 1588 page.th("", class_="emptyheading") 1589 1590 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1591 page.th(source, 1592 class_=(group_type == "request" and "requestheading" or "participantheading"), 1593 colspan=max(columns, 1)) 1594 1595 page.tr.close() 1596 page.thead.close() 1597 1598 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1599 1600 """ 1601 Show calendar days, defined by a collection of 'days', the contributing 1602 period information as 'partitioned_groups' (partitioned by day), the 1603 'partitioned_group_types' indicating the kind of contribution involved, 1604 and the 'group_columns' defining the number of columns in each group. 1605 """ 1606 1607 page = self.page 1608 1609 # Determine the number of columns required. Where participants provide 1610 # no columns for events, one still needs to be provided for the 1611 # participant itself. 1612 1613 all_columns = sum([max(columns, 1) for columns in group_columns]) 1614 1615 # Determine the days providing time slots. 1616 1617 all_days = days.items() 1618 all_days.sort() 1619 1620 # Produce a heading and time points for each day. 1621 1622 for day, intervals in all_days: 1623 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1624 is_empty = True 1625 1626 for slots in groups_for_day: 1627 if not slots: 1628 continue 1629 1630 for active in slots.values(): 1631 if active: 1632 is_empty = False 1633 break 1634 1635 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1636 page.tr() 1637 page.th(class_="dayheading container", colspan=all_columns+1) 1638 self._day_heading(day) 1639 page.th.close() 1640 page.tr.close() 1641 page.thead.close() 1642 1643 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1644 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1645 page.tbody.close() 1646 1647 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1648 1649 """ 1650 Show the time 'intervals' along with period information from the given 1651 'groups', having the indicated 'group_types', each with the number of 1652 columns given by 'group_columns'. 1653 """ 1654 1655 page = self.page 1656 1657 # Obtain the user's timezone. 1658 1659 tzid = self.get_tzid() 1660 1661 # Produce a row for each interval. 1662 1663 intervals = list(intervals) 1664 intervals.sort() 1665 1666 for point, endpoint in intervals: 1667 continuation = point == get_start_of_day(point, tzid) 1668 1669 # Some rows contain no period details and are marked as such. 1670 1671 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1672 1673 css = " ".join([ 1674 "slot", 1675 have_active and "busy" or "empty", 1676 continuation and "daystart" or "" 1677 ]) 1678 1679 page.tr(class_=css) 1680 page.th(class_="timeslot") 1681 self._time_point(point, endpoint) 1682 page.th.close() 1683 1684 # Obtain slots for the time point from each group. 1685 1686 for columns, slots, group_type in zip(group_columns, groups, group_types): 1687 active = slots and slots.get(point) 1688 1689 # Where no periods exist for the given time interval, generate 1690 # an empty cell. Where a participant provides no periods at all, 1691 # the colspan is adjusted to be 1, not 0. 1692 1693 if not active: 1694 page.td(class_="empty container", colspan=max(columns, 1)) 1695 self._empty_slot(point, endpoint) 1696 page.td.close() 1697 continue 1698 1699 slots = slots.items() 1700 slots.sort() 1701 spans = get_spans(slots) 1702 1703 empty = 0 1704 1705 # Show a column for each active period. 1706 1707 for t in active: 1708 if t and len(t) >= 2: 1709 1710 # Flush empty slots preceding this one. 1711 1712 if empty: 1713 page.td(class_="empty container", colspan=empty) 1714 self._empty_slot(point, endpoint) 1715 page.td.close() 1716 empty = 0 1717 1718 start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) 1719 span = spans[key] 1720 1721 # Produce a table cell only at the start of the period 1722 # or when continued at the start of a day. 1723 1724 if point == start or continuation: 1725 1726 has_continued = continuation and point != start 1727 will_continue = not ends_on_same_day(point, end, tzid) 1728 is_organiser = organiser == self.user 1729 1730 css = " ".join([ 1731 "event", 1732 has_continued and "continued" or "", 1733 will_continue and "continues" or "", 1734 is_organiser and "organising" or "attending" 1735 ]) 1736 1737 # Only anchor the first cell of events. 1738 # NOTE: Need to only anchor the first period for a 1739 # NOTE: recurring event. 1740 1741 if point == start: 1742 page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or "")) 1743 else: 1744 page.td(class_=css, rowspan=span) 1745 1746 # Only link to events if they are not being 1747 # updated by requests. 1748 1749 if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": 1750 page.span(summary or "(Participant is busy)") 1751 else: 1752 page.a(summary, href=self.link_to(uid, recurrenceid)) 1753 1754 page.td.close() 1755 else: 1756 empty += 1 1757 1758 # Pad with empty columns. 1759 1760 empty = columns - len(active) 1761 1762 if empty: 1763 page.td(class_="empty container", colspan=empty) 1764 self._empty_slot(point, endpoint) 1765 page.td.close() 1766 1767 page.tr.close() 1768 1769 def _day_heading(self, day): 1770 1771 """ 1772 Generate a heading for 'day' of the following form: 1773 1774 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1775 """ 1776 1777 page = self.page 1778 daystr = format_datetime(day) 1779 value, identifier = self._day_value_and_identifier(day) 1780 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1781 1782 def _time_point(self, point, endpoint): 1783 1784 """ 1785 Generate headings for the 'point' to 'endpoint' period of the following 1786 form: 1787 1788 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1789 <span class="endpoint">10:00:00 CET</span> 1790 """ 1791 1792 page = self.page 1793 tzid = self.get_tzid() 1794 daystr = format_datetime(point.date()) 1795 value, identifier = self._slot_value_and_identifier(point, endpoint) 1796 slots = self.env.get_args().get("slot", []) 1797 self._slot_selector(value, identifier, slots) 1798 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1799 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1800 1801 def _slot_selector(self, value, identifier, slots): 1802 reset = self.env.get_args().has_key("reset") 1803 page = self.page 1804 if not reset and value in slots: 1805 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1806 else: 1807 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1808 1809 def _empty_slot(self, point, endpoint): 1810 page = self.page 1811 value, identifier = self._slot_value_and_identifier(point, endpoint) 1812 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1813 1814 def _day_value_and_identifier(self, day): 1815 value = "%s-" % format_datetime(day) 1816 identifier = "day-%s" % value 1817 return value, identifier 1818 1819 def _slot_value_and_identifier(self, point, endpoint): 1820 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1821 identifier = "slot-%s" % value 1822 return value, identifier 1823 1824 def _show_menu(self, name, default, items, class_=""): 1825 page = self.page 1826 values = self.env.get_args().get(name, [default]) 1827 page.select(name=name, class_=class_) 1828 for v, label in items: 1829 if v is None: 1830 continue 1831 if v in values: 1832 page.option(label, value=v, selected="selected") 1833 else: 1834 page.option(label, value=v) 1835 page.select.close() 1836 1837 def _show_date_controls(self, name, default, attr, tzid): 1838 1839 """ 1840 Show date controls for a field with the given 'name' and 'default' value 1841 and 'attr', with the given 'tzid' being used if no other time regime 1842 information is provided. 1843 """ 1844 1845 page = self.page 1846 args = self.env.get_args() 1847 1848 event_tzid = attr.get("TZID", tzid) 1849 dt = get_datetime(default, attr) 1850 1851 # Show dates for up to one week around the current date. 1852 1853 base = get_date(dt) 1854 items = [] 1855 for i in range(-7, 8): 1856 d = base + timedelta(i) 1857 items.append((format_datetime(d), self.format_date(d, "full"))) 1858 1859 self._show_menu("%s-date" % name, format_datetime(base), items) 1860 1861 # Show time details. 1862 1863 dt_time = isinstance(dt, datetime) and dt or None 1864 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1865 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1866 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1867 1868 page.span(class_="time enabled") 1869 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1870 page.add(":") 1871 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1872 page.add(":") 1873 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1874 page.add(" ") 1875 self._show_menu("%s-tzid" % name, event_tzid, 1876 [(event_tzid, event_tzid)] + ( 1877 event_tzid != tzid and [(tzid, tzid)] or [] 1878 )) 1879 page.span.close() 1880 1881 # Incoming HTTP request direction. 1882 1883 def select_action(self): 1884 1885 "Select the desired action and show the result." 1886 1887 path_info = self.env.get_path_info().strip("/") 1888 1889 if not path_info: 1890 self.show_calendar() 1891 elif self.show_object(path_info): 1892 pass 1893 else: 1894 self.no_page() 1895 1896 def __call__(self): 1897 1898 "Interpret a request and show an appropriate response." 1899 1900 if not self.user: 1901 self.no_user() 1902 else: 1903 self.select_action() 1904 1905 # Write the headers and actual content. 1906 1907 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1908 print >>self.out 1909 self.out.write(unicode(self.page).encode(self.encoding)) 1910 1911 if __name__ == "__main__": 1912 Manager()() 1913 1914 # vim: tabstop=4 expandtab shiftwidth=4