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 property_items = [ 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, label in self.property_items: 684 page.tr() 685 686 # Handle datetimes specially. 687 688 if name in ["DTSTART", "DTEND"]: 689 value, attr = obj.get_item(name) 690 event_tzid = attr.get("TZID", tzid) 691 strvalue = ( 692 name == "DTSTART" and self.format_datetime or self.format_end_datetime 693 )(to_timezone(get_datetime(value), event_tzid), "full") 694 page.th(label, class_="objectheading") 695 696 if is_organiser: 697 page.td() 698 self._show_date_controls(name.lower(), value, attr, tzid) 699 page.td.close() 700 else: 701 page.td(strvalue) 702 703 page.tr.close() 704 705 # Handle the summary specially. 706 707 elif name == "SUMMARY": 708 value = obj.get_value(name) 709 page.th(label, class_="objectheading") 710 page.td() 711 if is_organiser: 712 page.input(name="summary", type="text", value=value, size=80) 713 else: 714 page.add(value) 715 page.td.close() 716 page.tr.close() 717 718 # Handle potentially many values. 719 720 else: 721 items = obj.get_items(name) 722 if not items: 723 continue 724 725 page.th(label, class_="objectheading", rowspan=len(items)) 726 727 first = True 728 729 for value, attr in items: 730 if not first: 731 page.tr() 732 else: 733 first = False 734 735 if name in ("ATTENDEE", "ORGANIZER"): 736 page.td(class_="objectattribute") 737 page.add(value) 738 page.add(" ") 739 740 partstat = attr.get("PARTSTAT") 741 if value == self.user and (not is_organiser or name == "ORGANIZER"): 742 self._show_menu("partstat", partstat, self.partstat_items) 743 else: 744 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 745 else: 746 page.td(class_="objectattribute") 747 page.add(value) 748 749 page.td.close() 750 page.tr.close() 751 752 page.tbody.close() 753 page.table.close() 754 755 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 756 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 757 758 # Indicate whether there are conflicting events. 759 760 freebusy = self.store.get_freebusy(self.user) 761 762 if freebusy: 763 764 # Obtain any time zone details from the suggested event. 765 766 _dtstart, attr = obj.get_item("DTSTART") 767 tzid = attr.get("TZID", tzid) 768 769 # Show any conflicts. 770 771 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 772 start, end, found_uid = t[:3] 773 774 # Provide details of any conflicting event. 775 776 if uid != found_uid: 777 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 778 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 779 page.p("Event conflicts with another from %s to %s: " % (start, end)) 780 781 # Show the event summary for the conflicting event. 782 783 found_obj = self._get_object(found_uid) 784 if found_obj: 785 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 786 787 self.show_request_controls(obj) 788 page.form.close() 789 790 def show_requests_on_page(self): 791 792 "Show requests for the current user." 793 794 # NOTE: This list could be more informative, but it is envisaged that 795 # NOTE: the requests would be visited directly anyway. 796 797 requests = self._get_requests() 798 799 self.page.div(id="pending-requests") 800 801 if requests: 802 self.page.p("Pending requests:") 803 804 self.page.ul() 805 806 for request in requests: 807 obj = self._get_object(request) 808 if obj: 809 self.page.li() 810 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 811 self.page.li.close() 812 813 self.page.ul.close() 814 815 else: 816 self.page.p("There are no pending requests.") 817 818 self.page.div.close() 819 820 def show_participants_on_page(self): 821 822 "Show participants for scheduling purposes." 823 824 args = self.env.get_args() 825 participants = args.get("participants", []) 826 827 try: 828 for name, value in args.items(): 829 if name.startswith("remove-participant-"): 830 i = int(name[len("remove-participant-"):]) 831 del participants[i] 832 break 833 except ValueError: 834 pass 835 836 # Trim empty participants. 837 838 while participants and not participants[-1].strip(): 839 participants.pop() 840 841 # Show any specified participants together with controls to remove and 842 # add participants. 843 844 self.page.div(id="participants") 845 846 self.page.p("Participants for scheduling:") 847 848 for i, participant in enumerate(participants): 849 self.page.p() 850 self.page.input(name="participants", type="text", value=participant) 851 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 852 self.page.p.close() 853 854 self.page.p() 855 self.page.input(name="participants", type="text") 856 self.page.input(name="add-participant", type="submit", value="Add") 857 self.page.p.close() 858 859 self.page.div.close() 860 861 return participants 862 863 # Full page output methods. 864 865 def show_object(self, path_info): 866 867 "Show an object request using the given 'path_info' for the current user." 868 869 uid = self._get_uid(path_info) 870 obj = self._get_object(uid) 871 872 if not obj: 873 return False 874 875 handled = self.handle_request(uid, obj) 876 877 if handled: 878 return True 879 880 self.new_page(title="Event") 881 self.show_object_on_page(uid, obj) 882 883 return True 884 885 def show_calendar(self): 886 887 "Show the calendar for the current user." 888 889 handled = self.handle_newevent() 890 891 self.new_page(title="Calendar") 892 page = self.page 893 894 # Form controls are used in various places on the calendar page. 895 896 page.form(method="POST") 897 898 self.show_requests_on_page() 899 participants = self.show_participants_on_page() 900 901 # Show a button for scheduling a new event. 902 903 page.p(class_="controls") 904 page.input(name="newevent", type="submit", value="New event", id="newevent") 905 page.input(name="reset", type="submit", value="Clear selections", id="reset") 906 page.p.close() 907 908 # Show controls for hiding empty days and busy slots. 909 # The positioning of the control, paragraph and table are important here. 910 911 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 912 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 913 914 page.p(class_="controls") 915 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 916 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 917 page.label("Show empty days", for_="showdays", class_="showdays disable") 918 page.label("Hide empty days", for_="showdays", class_="showdays enable") 919 page.p.close() 920 921 freebusy = self.store.get_freebusy(self.user) 922 923 if not freebusy: 924 page.p("No events scheduled.") 925 return 926 927 # Obtain the user's timezone. 928 929 tzid = self.get_tzid() 930 931 # Day view: start at the earliest known day and produce days until the 932 # latest known day, perhaps with expandable sections of empty days. 933 934 # Month view: start at the earliest known month and produce months until 935 # the latest known month, perhaps with expandable sections of empty 936 # months. 937 938 # Details of users to invite to new events could be superimposed on the 939 # calendar. 940 941 # Requests are listed and linked to their tentative positions in the 942 # calendar. Other participants are also shown. 943 944 request_summary = self._get_request_summary() 945 946 period_groups = [request_summary, freebusy] 947 period_group_types = ["request", "freebusy"] 948 period_group_sources = ["Pending requests", "Your schedule"] 949 950 for i, participant in enumerate(participants): 951 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 952 period_group_types.append("freebusy-part%d" % i) 953 period_group_sources.append(participant) 954 955 groups = [] 956 group_columns = [] 957 group_types = period_group_types 958 group_sources = period_group_sources 959 all_points = set() 960 961 # Obtain time point information for each group of periods. 962 963 for periods in period_groups: 964 periods = convert_periods(periods, tzid) 965 966 # Get the time scale with start and end points. 967 968 scale = get_scale(periods) 969 970 # Get the time slots for the periods. 971 972 slots = get_slots(scale) 973 974 # Add start of day time points for multi-day periods. 975 976 add_day_start_points(slots, tzid) 977 978 # Record the slots and all time points employed. 979 980 groups.append(slots) 981 all_points.update([point for point, active in slots]) 982 983 # Partition the groups into days. 984 985 days = {} 986 partitioned_groups = [] 987 partitioned_group_types = [] 988 partitioned_group_sources = [] 989 990 for slots, group_type, group_source in zip(groups, group_types, group_sources): 991 992 # Propagate time points to all groups of time slots. 993 994 add_slots(slots, all_points) 995 996 # Count the number of columns employed by the group. 997 998 columns = 0 999 1000 # Partition the time slots by day. 1001 1002 partitioned = {} 1003 1004 for day, day_slots in partition_by_day(slots).items(): 1005 intervals = [] 1006 last = None 1007 1008 for point, active in day_slots: 1009 columns = max(columns, len(active)) 1010 if last: 1011 intervals.append((last, point)) 1012 last = point 1013 1014 if last: 1015 intervals.append((last, None)) 1016 1017 if not days.has_key(day): 1018 days[day] = set() 1019 1020 # Convert each partition to a mapping from points to active 1021 # periods. 1022 1023 partitioned[day] = dict(day_slots) 1024 1025 # Record the divisions or intervals within each day. 1026 1027 days[day].update(intervals) 1028 1029 if group_type != "request" or columns: 1030 group_columns.append(columns) 1031 partitioned_groups.append(partitioned) 1032 partitioned_group_types.append(group_type) 1033 partitioned_group_sources.append(group_source) 1034 1035 # Add empty days. 1036 1037 add_empty_days(days, tzid) 1038 1039 # Show the controls permitting day selection. 1040 1041 self.show_calendar_day_controls(days) 1042 1043 # Show the calendar itself. 1044 1045 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1046 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1047 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1048 page.table.close() 1049 1050 # End the form region. 1051 1052 page.form.close() 1053 1054 # More page fragment methods. 1055 1056 def show_calendar_day_controls(self, days): 1057 1058 "Show controls for the given 'days' in the calendar." 1059 1060 page = self.page 1061 slots = self.env.get_args().get("slot", []) 1062 1063 for day in days: 1064 value, identifier = self._day_value_and_identifier(day) 1065 self._slot_selector(value, identifier, slots) 1066 1067 # Generate a dynamic stylesheet to allow day selections to colour 1068 # specific days. 1069 # NOTE: The style details need to be coordinated with the static 1070 # NOTE: stylesheet. 1071 1072 page.style(type="text/css") 1073 1074 for day in days: 1075 daystr = format_datetime(day) 1076 page.add("""\ 1077 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1078 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1079 background-color: #5f4; 1080 text-decoration: underline; 1081 } 1082 """ % (daystr, daystr, daystr, daystr)) 1083 1084 page.style.close() 1085 1086 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1087 1088 """ 1089 Show headings for the participants and other scheduling contributors, 1090 defined by 'group_types', 'group_sources' and 'group_columns'. 1091 """ 1092 1093 page = self.page 1094 1095 page.colgroup(span=1, id="columns-timeslot") 1096 1097 for group_type, columns in zip(group_types, group_columns): 1098 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1099 1100 page.thead() 1101 page.tr() 1102 page.th("", class_="emptyheading") 1103 1104 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1105 page.th(source, 1106 class_=(group_type == "request" and "requestheading" or "participantheading"), 1107 colspan=max(columns, 1)) 1108 1109 page.tr.close() 1110 page.thead.close() 1111 1112 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1113 1114 """ 1115 Show calendar days, defined by a collection of 'days', the contributing 1116 period information as 'partitioned_groups' (partitioned by day), the 1117 'partitioned_group_types' indicating the kind of contribution involved, 1118 and the 'group_columns' defining the number of columns in each group. 1119 """ 1120 1121 page = self.page 1122 1123 # Determine the number of columns required. Where participants provide 1124 # no columns for events, one still needs to be provided for the 1125 # participant itself. 1126 1127 all_columns = sum([max(columns, 1) for columns in group_columns]) 1128 1129 # Determine the days providing time slots. 1130 1131 all_days = days.items() 1132 all_days.sort() 1133 1134 # Produce a heading and time points for each day. 1135 1136 for day, intervals in all_days: 1137 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1138 is_empty = True 1139 1140 for slots in groups_for_day: 1141 if not slots: 1142 continue 1143 1144 for active in slots.values(): 1145 if active: 1146 is_empty = False 1147 break 1148 1149 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1150 page.tr() 1151 page.th(class_="dayheading container", colspan=all_columns+1) 1152 self._day_heading(day) 1153 page.th.close() 1154 page.tr.close() 1155 page.thead.close() 1156 1157 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1158 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1159 page.tbody.close() 1160 1161 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1162 1163 """ 1164 Show the time 'intervals' along with period information from the given 1165 'groups', having the indicated 'group_types', each with the number of 1166 columns given by 'group_columns'. 1167 """ 1168 1169 page = self.page 1170 1171 # Obtain the user's timezone. 1172 1173 tzid = self.get_tzid() 1174 1175 # Produce a row for each interval. 1176 1177 intervals = list(intervals) 1178 intervals.sort() 1179 1180 for point, endpoint in intervals: 1181 continuation = point == get_start_of_day(point, tzid) 1182 1183 # Some rows contain no period details and are marked as such. 1184 1185 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1186 1187 css = " ".join( 1188 ["slot"] + 1189 (have_active and ["busy"] or ["empty"]) + 1190 (continuation and ["daystart"] or []) 1191 ) 1192 1193 page.tr(class_=css) 1194 page.th(class_="timeslot") 1195 self._time_point(point, endpoint) 1196 page.th.close() 1197 1198 # Obtain slots for the time point from each group. 1199 1200 for columns, slots, group_type in zip(group_columns, groups, group_types): 1201 active = slots and slots.get(point) 1202 1203 # Where no periods exist for the given time interval, generate 1204 # an empty cell. Where a participant provides no periods at all, 1205 # the colspan is adjusted to be 1, not 0. 1206 1207 if not active: 1208 page.td(class_="empty container", colspan=max(columns, 1)) 1209 self._empty_slot(point, endpoint) 1210 page.td.close() 1211 continue 1212 1213 slots = slots.items() 1214 slots.sort() 1215 spans = get_spans(slots) 1216 1217 empty = 0 1218 1219 # Show a column for each active period. 1220 1221 for t in active: 1222 if t and len(t) >= 2: 1223 1224 # Flush empty slots preceding this one. 1225 1226 if empty: 1227 page.td(class_="empty container", colspan=empty) 1228 self._empty_slot(point, endpoint) 1229 page.td.close() 1230 empty = 0 1231 1232 start, end, uid, key = get_freebusy_details(t) 1233 span = spans[key] 1234 1235 # Produce a table cell only at the start of the period 1236 # or when continued at the start of a day. 1237 1238 if point == start or continuation: 1239 1240 obj = self._get_object(uid) 1241 1242 has_continued = continuation and point != start 1243 will_continue = not ends_on_same_day(point, end, tzid) 1244 is_organiser = obj.get_value("ORGANIZER") == self.user 1245 1246 css = " ".join( 1247 ["event"] + 1248 (has_continued and ["continued"] or []) + 1249 (will_continue and ["continues"] or []) + 1250 (is_organiser and ["organising"] or ["attending"]) 1251 ) 1252 1253 # Only anchor the first cell of events. 1254 1255 if point == start: 1256 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1257 else: 1258 page.td(class_=css, rowspan=span) 1259 1260 if not obj: 1261 page.span("") 1262 else: 1263 summary = obj.get_value("SUMMARY") 1264 1265 # Only link to events if they are not being 1266 # updated by requests. 1267 1268 if uid in self._get_requests() and group_type != "request": 1269 page.span(summary) 1270 else: 1271 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1272 page.a(summary, href=href) 1273 1274 page.td.close() 1275 else: 1276 empty += 1 1277 1278 # Pad with empty columns. 1279 1280 empty = columns - len(active) 1281 1282 if empty: 1283 page.td(class_="empty container", colspan=empty) 1284 self._empty_slot(point, endpoint) 1285 page.td.close() 1286 1287 page.tr.close() 1288 1289 def _day_heading(self, day): 1290 1291 """ 1292 Generate a heading for 'day' of the following form: 1293 1294 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1295 """ 1296 1297 page = self.page 1298 daystr = format_datetime(day) 1299 value, identifier = self._day_value_and_identifier(day) 1300 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1301 1302 def _time_point(self, point, endpoint): 1303 1304 """ 1305 Generate headings for the 'point' to 'endpoint' period of the following 1306 form: 1307 1308 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1309 <span class="endpoint">10:00:00 CET</span> 1310 """ 1311 1312 page = self.page 1313 tzid = self.get_tzid() 1314 daystr = format_datetime(point.date()) 1315 value, identifier = self._slot_value_and_identifier(point, endpoint) 1316 slots = self.env.get_args().get("slot", []) 1317 self._slot_selector(value, identifier, slots) 1318 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1319 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1320 1321 def _slot_selector(self, value, identifier, slots): 1322 reset = self.env.get_args().has_key("reset") 1323 page = self.page 1324 if not reset and value in slots: 1325 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1326 else: 1327 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1328 1329 def _empty_slot(self, point, endpoint): 1330 page = self.page 1331 value, identifier = self._slot_value_and_identifier(point, endpoint) 1332 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1333 1334 def _day_value_and_identifier(self, day): 1335 value = "%s-" % format_datetime(day) 1336 identifier = "day-%s" % value 1337 return value, identifier 1338 1339 def _slot_value_and_identifier(self, point, endpoint): 1340 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1341 identifier = "slot-%s" % value 1342 return value, identifier 1343 1344 def _show_menu(self, name, default, items): 1345 page = self.page 1346 values = self.env.get_args().get(name, [default]) 1347 page.select(name=name) 1348 for v, label in items: 1349 if v in values: 1350 page.option(label, value=v, selected="selected") 1351 else: 1352 page.option(label, value=v) 1353 page.select.close() 1354 1355 def _show_date_controls(self, name, default, attr, tzid): 1356 1357 """ 1358 Show date controls for a field with the given 'name' and 'default' value 1359 and 'attr', with the given 'tzid' being used if no other time regime 1360 information is provided. 1361 """ 1362 1363 page = self.page 1364 args = self.env.get_args() 1365 1366 event_tzid = attr.get("TZID", tzid) 1367 dt = get_datetime(default, attr) 1368 1369 # Show dates for up to one week around the current date. 1370 1371 base = get_date(dt) 1372 items = [] 1373 for i in range(-7, 8): 1374 d = base + timedelta(i) 1375 items.append((format_datetime(d), self.format_date(d, "full"))) 1376 1377 self._show_menu("%s-date" % name, format_datetime(base), items) 1378 1379 # Show time details. 1380 1381 if isinstance(dt, datetime): 1382 hour = args.get("%s-hour" % name, "%02d" % dt.hour) 1383 minute = args.get("%s-minute" % name, "%02d" % dt.minute) 1384 second = args.get("%s-second" % name, "%02d" % dt.second) 1385 page.add(" ") 1386 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1387 page.add(":") 1388 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1389 page.add(":") 1390 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1391 page.add(" ") 1392 self._show_menu("%s-tzid" % name, event_tzid, 1393 [(event_tzid, event_tzid)] + ( 1394 event_tzid != tzid and [(tzid, tzid)] or [] 1395 )) 1396 1397 # Incoming HTTP request direction. 1398 1399 def select_action(self): 1400 1401 "Select the desired action and show the result." 1402 1403 path_info = self.env.get_path_info().strip("/") 1404 1405 if not path_info: 1406 self.show_calendar() 1407 elif self.show_object(path_info): 1408 pass 1409 else: 1410 self.no_page() 1411 1412 def __call__(self): 1413 1414 "Interpret a request and show an appropriate response." 1415 1416 if not self.user: 1417 self.no_user() 1418 else: 1419 self.select_action() 1420 1421 # Write the headers and actual content. 1422 1423 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1424 print >>self.out 1425 self.out.write(unicode(self.page).encode(self.encoding)) 1426 1427 if __name__ == "__main__": 1428 Manager()() 1429 1430 # vim: tabstop=4 expandtab shiftwidth=4