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 # Obtain the user's timezone. 796 797 tzid = self.get_tzid() 798 799 # Provide controls to change the displayed object. 800 801 args = self.env.get_args() 802 803 # Add or remove new attendees. 804 # This does not affect the stored object. 805 806 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 807 new_attendees = args.get("added", []) 808 new_attendee = args.get("attendee", [""])[0] 809 810 if args.has_key("add"): 811 if new_attendee.strip(): 812 new_attendee = get_uri(new_attendee.strip()) 813 if new_attendee not in new_attendees and new_attendee not in existing_attendees: 814 new_attendees.append(new_attendee) 815 new_attendee = "" 816 817 if args.has_key("removenew"): 818 removed_attendee = args["removenew"][0] 819 if removed_attendee in new_attendees: 820 new_attendees.remove(removed_attendee) 821 822 # Configure the start and end datetimes. 823 824 dtend_control = args.get("dtend-control", [None])[0] 825 dttimes_control = args.get("dttimes-control", [None])[0] 826 with_time = dttimes_control == "enable" 827 828 t = self.handle_date_controls("dtstart", with_time) 829 if t: 830 dtstart, dtstart_attr = t 831 else: 832 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 833 834 if dtend_control == "enable": 835 t = self.handle_date_controls("dtend", with_time) 836 if t: 837 dtend, dtend_attr = t 838 else: 839 dtend, dtend_attr = None, {} 840 elif dtend_control == "disable": 841 dtend, dtend_attr = None, {} 842 else: 843 dtend, dtend_attr = obj.get_datetime_item("DTEND") 844 845 # Change end dates to refer to the actual dates, not the iCalendar 846 # "next day" dates. 847 848 if dtend and not isinstance(dtend, datetime): 849 dtend -= timedelta(1) 850 851 # Show the end datetime controls if already active or if an object needs 852 # them. 853 854 dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend 855 dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) 856 857 if dtend_enabled: 858 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 859 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 860 else: 861 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 862 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 863 864 if dttimes_enabled: 865 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") 866 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") 867 else: 868 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") 869 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") 870 871 # Provide a summary of the object. 872 873 page.table(class_="object", cellspacing=5, cellpadding=5) 874 page.thead() 875 page.tr() 876 page.th("Event", class_="mainheading", colspan=2) 877 page.tr.close() 878 page.thead.close() 879 page.tbody() 880 881 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 882 883 for name, label in self.property_items: 884 page.tr() 885 886 # Handle datetimes specially. 887 888 if name in ["DTSTART", "DTEND"]: 889 field = name.lower() 890 891 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or "")) 892 893 # Obtain the datetime. 894 895 if name == "DTSTART": 896 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 897 898 # Where no end datetime exists, use the start datetime as the 899 # basis of any potential datetime specified if dt-control is 900 # set. 901 902 else: 903 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 904 905 # Show controls for editing as organiser. 906 907 if is_organiser: 908 value = format_datetime(dt) 909 910 page.td(class_="objectvalue %s" % field) 911 if name == "DTEND": 912 page.div(class_="dt disabled") 913 page.label("Specify end date", for_="dtend-enable", class_="enable") 914 page.div.close() 915 916 page.div(class_="dt enabled") 917 self._show_date_controls(field, value, attr, tzid) 918 if name == "DTSTART": 919 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 920 page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") 921 elif name == "DTEND": 922 page.label("End on same day", for_="dtend-disable", class_="disable") 923 page.div.close() 924 925 page.td.close() 926 927 # Show a label as attendee. 928 929 else: 930 page.td(self.format_datetime(dt, "full")) 931 932 page.tr.close() 933 934 # Handle the summary specially. 935 936 elif name == "SUMMARY": 937 value = args.get("summary", [obj.get_value(name)])[0] 938 939 page.th(label, class_="objectheading") 940 page.td() 941 if is_organiser: 942 page.input(name="summary", type="text", value=value, size=80) 943 else: 944 page.add(value) 945 page.td.close() 946 page.tr.close() 947 948 # Handle potentially many values. 949 950 else: 951 items = obj.get_items(name) or [] 952 rowspan = len(items) 953 954 if name == "ATTENDEE": 955 rowspan += len(new_attendees) + 1 956 elif not items: 957 continue 958 959 page.th(label, class_="objectheading", rowspan=rowspan) 960 961 first = True 962 963 for i, (value, attr) in enumerate(items): 964 if not first: 965 page.tr() 966 else: 967 first = False 968 969 if name in ("ATTENDEE", "ORGANIZER"): 970 value = get_uri(value) 971 972 page.td(class_="objectvalue") 973 page.add(value) 974 page.add(" ") 975 976 partstat = attr.get("PARTSTAT") 977 if value == self.user and (not is_organiser or name == "ORGANIZER"): 978 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 979 else: 980 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 981 982 if is_organiser and name == "ATTENDEE": 983 if value in args.get("remove", []): 984 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") 985 else: 986 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") 987 page.label("Remove", for_="remove-%d" % i, class_="remove") 988 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 989 990 else: 991 page.td(class_="objectvalue") 992 page.add(value) 993 994 page.td.close() 995 page.tr.close() 996 997 # Allow more attendees to be specified. 998 999 if is_organiser and name == "ATTENDEE": 1000 for i, attendee in enumerate(new_attendees): 1001 if not first: 1002 page.tr() 1003 else: 1004 first = False 1005 1006 page.td() 1007 page.input(name="added", type="value", value=attendee) 1008 page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove") 1009 page.label("Remove", for_="removenew-%d" % i, class_="remove") 1010 page.td.close() 1011 page.tr.close() 1012 1013 if not first: 1014 page.tr() 1015 1016 page.td() 1017 page.input(name="attendee", type="value", value=new_attendee) 1018 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 1019 page.label("Add", for_="add-%d" % i, class_="add") 1020 page.td.close() 1021 page.tr.close() 1022 1023 page.tbody.close() 1024 page.table.close() 1025 1026 self.show_recurrences(obj) 1027 self.show_conflicting_events(uid, obj) 1028 self.show_request_controls(obj) 1029 1030 page.form.close() 1031 1032 def show_recurrences(self, obj): 1033 1034 "Show recurrences for the object having the given representation 'obj'." 1035 1036 page = self.page 1037 1038 # Obtain any parent object if this object is a specific recurrence. 1039 1040 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 1041 1042 if recurrenceid: 1043 obj = self._get_object(obj.get_value("UID")) 1044 if not obj: 1045 return 1046 1047 page.p("This event modifies a recurring event.") 1048 1049 # Obtain the periods associated with the event in the user's time zone. 1050 1051 periods = obj.get_periods(self.get_tzid(), self.get_window_end()) 1052 1053 if len(periods) == 1: 1054 return 1055 1056 page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) 1057 1058 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1059 page.thead() 1060 page.tr() 1061 page.th("Start") 1062 page.th("End") 1063 page.tr.close() 1064 page.thead.close() 1065 page.tbody() 1066 1067 for start, end in periods: 1068 start_utc = format_datetime(to_timezone(start, "UTC")) 1069 css = recurrenceid and start_utc == recurrenceid and "replaced" or "" 1070 1071 page.tr() 1072 page.td(self.format_datetime(start, "long"), class_=css) 1073 page.td(self.format_datetime(end, "long"), class_=css) 1074 page.tr.close() 1075 1076 page.tbody.close() 1077 page.table.close() 1078 1079 def show_conflicting_events(self, uid, obj): 1080 1081 """ 1082 Show conflicting events for the object having the given 'uid' and 1083 representation 'obj'. 1084 """ 1085 1086 page = self.page 1087 1088 # Obtain the user's timezone. 1089 1090 tzid = self.get_tzid() 1091 1092 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 1093 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 1094 1095 # Indicate whether there are conflicting events. 1096 1097 freebusy = self.store.get_freebusy(self.user) 1098 1099 if freebusy: 1100 1101 # Obtain any time zone details from the suggested event. 1102 1103 _dtstart, attr = obj.get_item("DTSTART") 1104 tzid = attr.get("TZID", tzid) 1105 1106 # Show any conflicts. 1107 1108 conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid] 1109 1110 if conflicts: 1111 page.p("This event conflicts with others:") 1112 1113 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1114 page.thead() 1115 page.tr() 1116 page.th("Event") 1117 page.th("Start") 1118 page.th("End") 1119 page.tr.close() 1120 page.thead.close() 1121 page.tbody() 1122 1123 for t in conflicts: 1124 start, end, found_uid, transp, found_recurrenceid = t[:5] 1125 1126 # Provide details of any conflicting event. 1127 1128 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 1129 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 1130 1131 page.tr() 1132 1133 # Show the event summary for the conflicting event. 1134 1135 page.td() 1136 1137 found_obj = self._get_object(found_uid, found_recurrenceid) 1138 if found_obj: 1139 page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid)) 1140 else: 1141 page.add("No details available") 1142 1143 page.td.close() 1144 1145 page.td(start) 1146 page.td(end) 1147 1148 page.tr.close() 1149 1150 page.tbody.close() 1151 page.table.close() 1152 1153 def show_requests_on_page(self): 1154 1155 "Show requests for the current user." 1156 1157 # NOTE: This list could be more informative, but it is envisaged that 1158 # NOTE: the requests would be visited directly anyway. 1159 1160 requests = self._get_requests() 1161 1162 self.page.div(id="pending-requests") 1163 1164 if requests: 1165 self.page.p("Pending requests:") 1166 1167 self.page.ul() 1168 1169 for uid, recurrenceid in requests: 1170 obj = self._get_object(uid, recurrenceid) 1171 if obj: 1172 self.page.li() 1173 self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 1174 self.page.li.close() 1175 1176 self.page.ul.close() 1177 1178 else: 1179 self.page.p("There are no pending requests.") 1180 1181 self.page.div.close() 1182 1183 def show_participants_on_page(self): 1184 1185 "Show participants for scheduling purposes." 1186 1187 args = self.env.get_args() 1188 participants = args.get("participants", []) 1189 1190 try: 1191 for name, value in args.items(): 1192 if name.startswith("remove-participant-"): 1193 i = int(name[len("remove-participant-"):]) 1194 del participants[i] 1195 break 1196 except ValueError: 1197 pass 1198 1199 # Trim empty participants. 1200 1201 while participants and not participants[-1].strip(): 1202 participants.pop() 1203 1204 # Show any specified participants together with controls to remove and 1205 # add participants. 1206 1207 self.page.div(id="participants") 1208 1209 self.page.p("Participants for scheduling:") 1210 1211 for i, participant in enumerate(participants): 1212 self.page.p() 1213 self.page.input(name="participants", type="text", value=participant) 1214 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1215 self.page.p.close() 1216 1217 self.page.p() 1218 self.page.input(name="participants", type="text") 1219 self.page.input(name="add-participant", type="submit", value="Add") 1220 self.page.p.close() 1221 1222 self.page.div.close() 1223 1224 return participants 1225 1226 # Full page output methods. 1227 1228 def show_object(self, path_info): 1229 1230 "Show an object request using the given 'path_info' for the current user." 1231 1232 uid, recurrenceid = self._get_identifiers(path_info) 1233 obj = self._get_object(uid, recurrenceid) 1234 1235 if not obj: 1236 return False 1237 1238 error = self.handle_request(uid, obj) 1239 1240 if not error: 1241 return True 1242 1243 self.new_page(title="Event") 1244 self.show_object_on_page(uid, obj, error) 1245 1246 return True 1247 1248 def show_calendar(self): 1249 1250 "Show the calendar for the current user." 1251 1252 handled = self.handle_newevent() 1253 1254 self.new_page(title="Calendar") 1255 page = self.page 1256 1257 # Form controls are used in various places on the calendar page. 1258 1259 page.form(method="POST") 1260 1261 self.show_requests_on_page() 1262 participants = self.show_participants_on_page() 1263 1264 # Show a button for scheduling a new event. 1265 1266 page.p(class_="controls") 1267 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 1268 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1269 page.p.close() 1270 1271 # Show controls for hiding empty days and busy slots. 1272 # The positioning of the control, paragraph and table are important here. 1273 1274 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1275 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1276 1277 page.p(class_="controls") 1278 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1279 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1280 page.label("Show empty days", for_="showdays", class_="showdays disable") 1281 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1282 page.p.close() 1283 1284 freebusy = self.store.get_freebusy(self.user) 1285 1286 if not freebusy: 1287 page.p("No events scheduled.") 1288 return 1289 1290 # Obtain the user's timezone. 1291 1292 tzid = self.get_tzid() 1293 1294 # Day view: start at the earliest known day and produce days until the 1295 # latest known day, perhaps with expandable sections of empty days. 1296 1297 # Month view: start at the earliest known month and produce months until 1298 # the latest known month, perhaps with expandable sections of empty 1299 # months. 1300 1301 # Details of users to invite to new events could be superimposed on the 1302 # calendar. 1303 1304 # Requests are listed and linked to their tentative positions in the 1305 # calendar. Other participants are also shown. 1306 1307 request_summary = self._get_request_summary() 1308 1309 period_groups = [request_summary, freebusy] 1310 period_group_types = ["request", "freebusy"] 1311 period_group_sources = ["Pending requests", "Your schedule"] 1312 1313 for i, participant in enumerate(participants): 1314 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1315 period_group_types.append("freebusy-part%d" % i) 1316 period_group_sources.append(participant) 1317 1318 groups = [] 1319 group_columns = [] 1320 group_types = period_group_types 1321 group_sources = period_group_sources 1322 all_points = set() 1323 1324 # Obtain time point information for each group of periods. 1325 1326 for periods in period_groups: 1327 periods = convert_periods(periods, tzid) 1328 1329 # Get the time scale with start and end points. 1330 1331 scale = get_scale(periods) 1332 1333 # Get the time slots for the periods. 1334 1335 slots = get_slots(scale) 1336 1337 # Add start of day time points for multi-day periods. 1338 1339 add_day_start_points(slots, tzid) 1340 1341 # Record the slots and all time points employed. 1342 1343 groups.append(slots) 1344 all_points.update([point for point, active in slots]) 1345 1346 # Partition the groups into days. 1347 1348 days = {} 1349 partitioned_groups = [] 1350 partitioned_group_types = [] 1351 partitioned_group_sources = [] 1352 1353 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1354 1355 # Propagate time points to all groups of time slots. 1356 1357 add_slots(slots, all_points) 1358 1359 # Count the number of columns employed by the group. 1360 1361 columns = 0 1362 1363 # Partition the time slots by day. 1364 1365 partitioned = {} 1366 1367 for day, day_slots in partition_by_day(slots).items(): 1368 intervals = [] 1369 last = None 1370 1371 for point, active in day_slots: 1372 columns = max(columns, len(active)) 1373 if last: 1374 intervals.append((last, point)) 1375 last = point 1376 1377 if last: 1378 intervals.append((last, None)) 1379 1380 if not days.has_key(day): 1381 days[day] = set() 1382 1383 # Convert each partition to a mapping from points to active 1384 # periods. 1385 1386 partitioned[day] = dict(day_slots) 1387 1388 # Record the divisions or intervals within each day. 1389 1390 days[day].update(intervals) 1391 1392 if group_type != "request" or columns: 1393 group_columns.append(columns) 1394 partitioned_groups.append(partitioned) 1395 partitioned_group_types.append(group_type) 1396 partitioned_group_sources.append(group_source) 1397 1398 # Add empty days. 1399 1400 add_empty_days(days, tzid) 1401 1402 # Show the controls permitting day selection. 1403 1404 self.show_calendar_day_controls(days) 1405 1406 # Show the calendar itself. 1407 1408 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1409 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1410 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1411 page.table.close() 1412 1413 # End the form region. 1414 1415 page.form.close() 1416 1417 # More page fragment methods. 1418 1419 def show_calendar_day_controls(self, days): 1420 1421 "Show controls for the given 'days' in the calendar." 1422 1423 page = self.page 1424 slots = self.env.get_args().get("slot", []) 1425 1426 for day in days: 1427 value, identifier = self._day_value_and_identifier(day) 1428 self._slot_selector(value, identifier, slots) 1429 1430 # Generate a dynamic stylesheet to allow day selections to colour 1431 # specific days. 1432 # NOTE: The style details need to be coordinated with the static 1433 # NOTE: stylesheet. 1434 1435 page.style(type="text/css") 1436 1437 for day in days: 1438 daystr = format_datetime(day) 1439 page.add("""\ 1440 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1441 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1442 background-color: #5f4; 1443 text-decoration: underline; 1444 } 1445 """ % (daystr, daystr, daystr, daystr)) 1446 1447 page.style.close() 1448 1449 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1450 1451 """ 1452 Show headings for the participants and other scheduling contributors, 1453 defined by 'group_types', 'group_sources' and 'group_columns'. 1454 """ 1455 1456 page = self.page 1457 1458 page.colgroup(span=1, id="columns-timeslot") 1459 1460 for group_type, columns in zip(group_types, group_columns): 1461 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1462 1463 page.thead() 1464 page.tr() 1465 page.th("", class_="emptyheading") 1466 1467 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1468 page.th(source, 1469 class_=(group_type == "request" and "requestheading" or "participantheading"), 1470 colspan=max(columns, 1)) 1471 1472 page.tr.close() 1473 page.thead.close() 1474 1475 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1476 1477 """ 1478 Show calendar days, defined by a collection of 'days', the contributing 1479 period information as 'partitioned_groups' (partitioned by day), the 1480 'partitioned_group_types' indicating the kind of contribution involved, 1481 and the 'group_columns' defining the number of columns in each group. 1482 """ 1483 1484 page = self.page 1485 1486 # Determine the number of columns required. Where participants provide 1487 # no columns for events, one still needs to be provided for the 1488 # participant itself. 1489 1490 all_columns = sum([max(columns, 1) for columns in group_columns]) 1491 1492 # Determine the days providing time slots. 1493 1494 all_days = days.items() 1495 all_days.sort() 1496 1497 # Produce a heading and time points for each day. 1498 1499 for day, intervals in all_days: 1500 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1501 is_empty = True 1502 1503 for slots in groups_for_day: 1504 if not slots: 1505 continue 1506 1507 for active in slots.values(): 1508 if active: 1509 is_empty = False 1510 break 1511 1512 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1513 page.tr() 1514 page.th(class_="dayheading container", colspan=all_columns+1) 1515 self._day_heading(day) 1516 page.th.close() 1517 page.tr.close() 1518 page.thead.close() 1519 1520 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1521 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1522 page.tbody.close() 1523 1524 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1525 1526 """ 1527 Show the time 'intervals' along with period information from the given 1528 'groups', having the indicated 'group_types', each with the number of 1529 columns given by 'group_columns'. 1530 """ 1531 1532 page = self.page 1533 1534 # Obtain the user's timezone. 1535 1536 tzid = self.get_tzid() 1537 1538 # Produce a row for each interval. 1539 1540 intervals = list(intervals) 1541 intervals.sort() 1542 1543 for point, endpoint in intervals: 1544 continuation = point == get_start_of_day(point, tzid) 1545 1546 # Some rows contain no period details and are marked as such. 1547 1548 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1549 1550 css = " ".join( 1551 ["slot"] + 1552 (have_active and ["busy"] or ["empty"]) + 1553 (continuation and ["daystart"] or []) 1554 ) 1555 1556 page.tr(class_=css) 1557 page.th(class_="timeslot") 1558 self._time_point(point, endpoint) 1559 page.th.close() 1560 1561 # Obtain slots for the time point from each group. 1562 1563 for columns, slots, group_type in zip(group_columns, groups, group_types): 1564 active = slots and slots.get(point) 1565 1566 # Where no periods exist for the given time interval, generate 1567 # an empty cell. Where a participant provides no periods at all, 1568 # the colspan is adjusted to be 1, not 0. 1569 1570 if not active: 1571 page.td(class_="empty container", colspan=max(columns, 1)) 1572 self._empty_slot(point, endpoint) 1573 page.td.close() 1574 continue 1575 1576 slots = slots.items() 1577 slots.sort() 1578 spans = get_spans(slots) 1579 1580 empty = 0 1581 1582 # Show a column for each active period. 1583 1584 for t in active: 1585 if t and len(t) >= 2: 1586 1587 # Flush empty slots preceding this one. 1588 1589 if empty: 1590 page.td(class_="empty container", colspan=empty) 1591 self._empty_slot(point, endpoint) 1592 page.td.close() 1593 empty = 0 1594 1595 start, end, uid, recurrenceid, key = get_freebusy_details(t) 1596 span = spans[key] 1597 1598 # Produce a table cell only at the start of the period 1599 # or when continued at the start of a day. 1600 1601 if point == start or continuation: 1602 1603 obj = self._get_object(uid, recurrenceid) 1604 1605 has_continued = continuation and point != start 1606 will_continue = not ends_on_same_day(point, end, tzid) 1607 is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user 1608 1609 css = " ".join( 1610 ["event"] + 1611 (has_continued and ["continued"] or []) + 1612 (will_continue and ["continues"] or []) + 1613 (is_organiser and ["organising"] or ["attending"]) 1614 ) 1615 1616 # Only anchor the first cell of events. 1617 # NOTE: Need to only anchor the first period for a 1618 # NOTE: recurring event. 1619 1620 if point == start: 1621 page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or "")) 1622 else: 1623 page.td(class_=css, rowspan=span) 1624 1625 if not obj: 1626 page.span("(Participant is busy)") 1627 else: 1628 summary = obj.get_value("SUMMARY") 1629 1630 # Only link to events if they are not being 1631 # updated by requests. 1632 1633 if (uid, recurrenceid) in self._get_requests() and group_type != "request": 1634 page.span(summary) 1635 else: 1636 page.a(summary, href=self.link_to(uid, recurrenceid)) 1637 1638 page.td.close() 1639 else: 1640 empty += 1 1641 1642 # Pad with empty columns. 1643 1644 empty = columns - len(active) 1645 1646 if empty: 1647 page.td(class_="empty container", colspan=empty) 1648 self._empty_slot(point, endpoint) 1649 page.td.close() 1650 1651 page.tr.close() 1652 1653 def _day_heading(self, day): 1654 1655 """ 1656 Generate a heading for 'day' of the following form: 1657 1658 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1659 """ 1660 1661 page = self.page 1662 daystr = format_datetime(day) 1663 value, identifier = self._day_value_and_identifier(day) 1664 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1665 1666 def _time_point(self, point, endpoint): 1667 1668 """ 1669 Generate headings for the 'point' to 'endpoint' period of the following 1670 form: 1671 1672 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1673 <span class="endpoint">10:00:00 CET</span> 1674 """ 1675 1676 page = self.page 1677 tzid = self.get_tzid() 1678 daystr = format_datetime(point.date()) 1679 value, identifier = self._slot_value_and_identifier(point, endpoint) 1680 slots = self.env.get_args().get("slot", []) 1681 self._slot_selector(value, identifier, slots) 1682 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1683 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1684 1685 def _slot_selector(self, value, identifier, slots): 1686 reset = self.env.get_args().has_key("reset") 1687 page = self.page 1688 if not reset and value in slots: 1689 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1690 else: 1691 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1692 1693 def _empty_slot(self, point, endpoint): 1694 page = self.page 1695 value, identifier = self._slot_value_and_identifier(point, endpoint) 1696 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1697 1698 def _day_value_and_identifier(self, day): 1699 value = "%s-" % format_datetime(day) 1700 identifier = "day-%s" % value 1701 return value, identifier 1702 1703 def _slot_value_and_identifier(self, point, endpoint): 1704 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1705 identifier = "slot-%s" % value 1706 return value, identifier 1707 1708 def _show_menu(self, name, default, items, class_=""): 1709 page = self.page 1710 values = self.env.get_args().get(name, [default]) 1711 page.select(name=name, class_=class_) 1712 for v, label in items: 1713 if v is None: 1714 continue 1715 if v in values: 1716 page.option(label, value=v, selected="selected") 1717 else: 1718 page.option(label, value=v) 1719 page.select.close() 1720 1721 def _show_date_controls(self, name, default, attr, tzid): 1722 1723 """ 1724 Show date controls for a field with the given 'name' and 'default' value 1725 and 'attr', with the given 'tzid' being used if no other time regime 1726 information is provided. 1727 """ 1728 1729 page = self.page 1730 args = self.env.get_args() 1731 1732 event_tzid = attr.get("TZID", tzid) 1733 dt = get_datetime(default, attr) 1734 1735 # Show dates for up to one week around the current date. 1736 1737 base = get_date(dt) 1738 items = [] 1739 for i in range(-7, 8): 1740 d = base + timedelta(i) 1741 items.append((format_datetime(d), self.format_date(d, "full"))) 1742 1743 self._show_menu("%s-date" % name, format_datetime(base), items) 1744 1745 # Show time details. 1746 1747 dt_time = isinstance(dt, datetime) and dt or None 1748 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1749 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1750 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1751 1752 page.span(class_="time enabled") 1753 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1754 page.add(":") 1755 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1756 page.add(":") 1757 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1758 page.add(" ") 1759 self._show_menu("%s-tzid" % name, event_tzid, 1760 [(event_tzid, event_tzid)] + ( 1761 event_tzid != tzid and [(tzid, tzid)] or [] 1762 )) 1763 page.span.close() 1764 1765 # Incoming HTTP request direction. 1766 1767 def select_action(self): 1768 1769 "Select the desired action and show the result." 1770 1771 path_info = self.env.get_path_info().strip("/") 1772 1773 if not path_info: 1774 self.show_calendar() 1775 elif self.show_object(path_info): 1776 pass 1777 else: 1778 self.no_page() 1779 1780 def __call__(self): 1781 1782 "Interpret a request and show an appropriate response." 1783 1784 if not self.user: 1785 self.no_user() 1786 else: 1787 self.select_action() 1788 1789 # Write the headers and actual content. 1790 1791 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1792 print >>self.out 1793 self.out.write(unicode(self.page).encode(self.encoding)) 1794 1795 if __name__ == "__main__": 1796 Manager()() 1797 1798 # vim: tabstop=4 expandtab shiftwidth=4