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