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