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