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