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