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