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