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