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