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