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