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 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 # 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 489 if is_organiser: 490 t = self.handle_date_controls("dtstart") 491 if t: 492 dtstart, attr = t 493 update = update or self.set_datetime_in_object(dtstart, attr["TZID"], "DTSTART", obj) 494 else: 495 return False 496 497 # Handle specified end datetimes. 498 499 if args.get("dtend-control", [None])[0] == "enable": 500 t = self.handle_date_controls("dtend") 501 if t: 502 dtend, attr = t 503 504 # Convert end dates to iCalendar "next day" dates. 505 506 if not isinstance(dtend, datetime): 507 dtend += timedelta(1) 508 update = update or self.set_datetime_in_object(dtend, attr["TZID"], "DTEND", obj) 509 else: 510 return False 511 512 # Otherwise, treat the end date as the start date. Datetimes cannot 513 # be duplicated in such a way. 514 515 else: 516 if isinstance(dtstart, datetime): 517 return False 518 dtend = dtstart + timedelta(1) 519 update = update or self.set_datetime_in_object(dtend, attr["TZID"], "DTEND", obj) 520 521 if dtstart >= dtend: 522 return False 523 524 # Process any action. 525 526 reply = args.has_key("reply") 527 discard = args.has_key("discard") 528 invite = args.has_key("invite") 529 cancel = args.has_key("cancel") 530 save = args.has_key("save") 531 532 if reply or invite or cancel: 533 534 handler = ManagerHandler(obj, self.user, self.messenger) 535 536 # Process the object and remove it from the list of requests. 537 538 if reply and handler.process_received_request(update) or \ 539 (invite or cancel) and handler.process_created_request(invite and "REQUEST" or "CANCEL", update): 540 541 self.remove_request(uid) 542 543 # Save single user events. 544 545 elif save: 546 self.store.set_event(self.user, uid, obj.to_node()) 547 freebusy = self.store.get_freebusy(self.user) 548 self.remove_request(uid) 549 550 # Remove the request and the object. 551 552 elif discard: 553 self.remove_event(uid) 554 self.remove_request(uid) 555 556 else: 557 handled = False 558 559 # Upon handling an action, redirect to the main page. 560 561 if handled: 562 self.redirect(self.env.get_path()) 563 564 return handled 565 566 def handle_date_controls(self, name): 567 568 """ 569 Handle date control information for fields starting with 'name', 570 returning a (datetime, attr) tuple or None if the fields cannot be used 571 to construct a datetime object. 572 """ 573 574 args = self.env.get_args() 575 tzid = self.get_tzid() 576 577 if args.has_key("%s-date" % name): 578 date = args["%s-date" % name][0] 579 hour = args.get("%s-hour" % name, [None])[0] 580 minute = args.get("%s-minute" % name, [None])[0] 581 second = args.get("%s-second" % name, [None])[0] 582 tzid = args.get("%s-tzid" % name, [tzid])[0] 583 584 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or "" 585 value = "%s%s" % (date, time) 586 attr = {"TZID" : tzid} 587 dt = get_datetime(value, attr) 588 if dt: 589 return dt, attr 590 591 return None 592 593 def set_datetime_in_object(self, dt, tzid, property, obj): 594 595 """ 596 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 597 an update has occurred. 598 """ 599 600 if dt: 601 old_value = obj.get_value(property) 602 obj[property] = [get_datetime_item(dt, tzid)] 603 return format_datetime(dt) != old_value 604 605 return False 606 607 # Page fragment methods. 608 609 def show_request_controls(self, obj): 610 611 "Show form controls for a request concerning 'obj'." 612 613 page = self.page 614 615 is_organiser = obj.get_value("ORGANIZER") == self.user 616 617 attendees = obj.get_value_map("ATTENDEE") 618 is_attendee = attendees.has_key(self.user) 619 attendee_attr = attendees.get(self.user) 620 621 is_request = obj.get_value("UID") in self._get_requests() 622 623 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 624 625 # Show appropriate options depending on the role of the user. 626 627 if is_attendee and not is_organiser: 628 page.p("An action is required for this request:") 629 630 page.p() 631 page.input(name="reply", type="submit", value="Reply") 632 page.add(" ") 633 page.input(name="discard", type="submit", value="Discard") 634 page.p.close() 635 636 if is_organiser: 637 if have_other_attendees: 638 page.p("As organiser, you can perform the following:") 639 640 page.p() 641 page.input(name="invite", type="submit", value="Invite") 642 page.add(" ") 643 if is_request: 644 page.input(name="discard", type="submit", value="Discard") 645 else: 646 page.input(name="cancel", type="submit", value="Cancel") 647 page.p.close() 648 else: 649 page.p() 650 page.input(name="save", type="submit", value="Save") 651 page.add(" ") 652 page.input(name="discard", type="submit", value="Discard") 653 page.p.close() 654 655 property_items = [ 656 ("SUMMARY", "Summary"), 657 ("DTSTART", "Start"), 658 ("DTEND", "End"), 659 ("ORGANIZER", "Organiser"), 660 ("ATTENDEE", "Attendee"), 661 ] 662 663 partstat_items = [ 664 ("NEEDS-ACTION", "Not confirmed"), 665 ("ACCEPTED", "Attending"), 666 ("TENTATIVE", "Tentatively attending"), 667 ("DECLINED", "Not attending"), 668 ("DELEGATED", "Delegated"), 669 ] 670 671 def show_object_on_page(self, uid, obj): 672 673 """ 674 Show the calendar object with the given 'uid' and representation 'obj' 675 on the current page. 676 """ 677 678 page = self.page 679 page.form(method="POST") 680 681 # Obtain the user's timezone. 682 683 tzid = self.get_tzid() 684 685 # Provide controls to change the displayed object. 686 687 args = self.env.get_args() 688 689 t = self.handle_date_controls("dtstart") 690 if t: 691 dtstart, dtstart_attr = t 692 else: 693 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 694 695 dtend, dtend_attr = None, {} 696 697 if args.get("dtend-control", [None])[0] == "enable": 698 t = self.handle_date_controls("dtend") 699 if t: 700 dtend, dtend_attr = t 701 elif not args.has_key("dtend-control"): 702 dtend, dtend_attr = obj.get_datetime_item("DTEND") 703 704 # Change end dates to refer to the actual dates, not the iCalendar 705 # "next day" dates. 706 707 if dtend and not isinstance(dtend, datetime): 708 dtend -= timedelta(1) 709 710 if dtend and (isinstance(dtend, datetime) or dtstart != dtend): 711 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 712 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 713 else: 714 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 715 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 716 717 # Provide a summary of the object. 718 719 page.table(class_="object", cellspacing=5, cellpadding=5) 720 page.thead() 721 page.tr() 722 page.th("Event", class_="mainheading", colspan=2) 723 page.tr.close() 724 page.thead.close() 725 page.tbody() 726 727 is_organiser = obj.get_value("ORGANIZER") == self.user 728 729 for name, label in self.property_items: 730 page.tr() 731 732 # Handle datetimes specially. 733 734 if name in ["DTSTART", "DTEND"]: 735 736 page.th(label, class_="objectheading %s" % name.lower()) 737 738 if name == "DTSTART": 739 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 740 else: 741 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 742 743 strvalue = self.format_datetime(dt, "full") 744 value = format_datetime(dt) 745 746 if is_organiser: 747 page.td(class_="objectvalue %s" % name.lower()) 748 if name == "DTEND": 749 page.div(class_="disabled") 750 page.label("Specify end date", for_="dtend-enable", class_="enable") 751 page.div.close() 752 753 page.div(class_="enabled") 754 self._show_date_controls(name.lower(), value, attr, tzid) 755 if name == "DTEND": 756 page.label("End on same day", for_="dtend-disable", class_="disable") 757 page.div.close() 758 759 page.td.close() 760 else: 761 page.td(strvalue) 762 763 page.tr.close() 764 765 # Handle the summary specially. 766 767 elif name == "SUMMARY": 768 value = args.get("summary", [obj.get_value(name)])[0] 769 770 page.th(label, class_="objectheading") 771 page.td() 772 if is_organiser: 773 page.input(name="summary", type="text", value=value, size=80) 774 else: 775 page.add(value) 776 page.td.close() 777 page.tr.close() 778 779 # Handle potentially many values. 780 781 else: 782 items = obj.get_items(name) 783 if not items: 784 continue 785 786 page.th(label, class_="objectheading", rowspan=len(items)) 787 788 first = True 789 790 for value, attr in items: 791 if not first: 792 page.tr() 793 else: 794 first = False 795 796 if name in ("ATTENDEE", "ORGANIZER"): 797 page.td(class_="objectattribute") 798 page.add(value) 799 page.add(" ") 800 801 partstat = attr.get("PARTSTAT") 802 if value == self.user and (not is_organiser or name == "ORGANIZER"): 803 self._show_menu("partstat", partstat, self.partstat_items) 804 else: 805 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 806 else: 807 page.td(class_="objectattribute") 808 page.add(value) 809 810 page.td.close() 811 page.tr.close() 812 813 page.tbody.close() 814 page.table.close() 815 816 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 817 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 818 819 # Indicate whether there are conflicting events. 820 821 freebusy = self.store.get_freebusy(self.user) 822 823 if freebusy: 824 825 # Obtain any time zone details from the suggested event. 826 827 _dtstart, attr = obj.get_item("DTSTART") 828 tzid = attr.get("TZID", tzid) 829 830 # Show any conflicts. 831 832 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 833 start, end, found_uid = t[:3] 834 835 # Provide details of any conflicting event. 836 837 if uid != found_uid: 838 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 839 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 840 page.p("Event conflicts with another from %s to %s: " % (start, end)) 841 842 # Show the event summary for the conflicting event. 843 844 found_obj = self._get_object(found_uid) 845 if found_obj: 846 page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) 847 848 self.show_request_controls(obj) 849 page.form.close() 850 851 def show_requests_on_page(self): 852 853 "Show requests for the current user." 854 855 # NOTE: This list could be more informative, but it is envisaged that 856 # NOTE: the requests would be visited directly anyway. 857 858 requests = self._get_requests() 859 860 self.page.div(id="pending-requests") 861 862 if requests: 863 self.page.p("Pending requests:") 864 865 self.page.ul() 866 867 for request in requests: 868 obj = self._get_object(request) 869 if obj: 870 self.page.li() 871 self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) 872 self.page.li.close() 873 874 self.page.ul.close() 875 876 else: 877 self.page.p("There are no pending requests.") 878 879 self.page.div.close() 880 881 def show_participants_on_page(self): 882 883 "Show participants for scheduling purposes." 884 885 args = self.env.get_args() 886 participants = args.get("participants", []) 887 888 try: 889 for name, value in args.items(): 890 if name.startswith("remove-participant-"): 891 i = int(name[len("remove-participant-"):]) 892 del participants[i] 893 break 894 except ValueError: 895 pass 896 897 # Trim empty participants. 898 899 while participants and not participants[-1].strip(): 900 participants.pop() 901 902 # Show any specified participants together with controls to remove and 903 # add participants. 904 905 self.page.div(id="participants") 906 907 self.page.p("Participants for scheduling:") 908 909 for i, participant in enumerate(participants): 910 self.page.p() 911 self.page.input(name="participants", type="text", value=participant) 912 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 913 self.page.p.close() 914 915 self.page.p() 916 self.page.input(name="participants", type="text") 917 self.page.input(name="add-participant", type="submit", value="Add") 918 self.page.p.close() 919 920 self.page.div.close() 921 922 return participants 923 924 # Full page output methods. 925 926 def show_object(self, path_info): 927 928 "Show an object request using the given 'path_info' for the current user." 929 930 uid = self._get_uid(path_info) 931 obj = self._get_object(uid) 932 933 if not obj: 934 return False 935 936 handled = self.handle_request(uid, obj) 937 938 if handled: 939 return True 940 941 self.new_page(title="Event") 942 self.show_object_on_page(uid, obj) 943 944 return True 945 946 def show_calendar(self): 947 948 "Show the calendar for the current user." 949 950 handled = self.handle_newevent() 951 952 self.new_page(title="Calendar") 953 page = self.page 954 955 # Form controls are used in various places on the calendar page. 956 957 page.form(method="POST") 958 959 self.show_requests_on_page() 960 participants = self.show_participants_on_page() 961 962 # Show a button for scheduling a new event. 963 964 page.p(class_="controls") 965 page.input(name="newevent", type="submit", value="New event", id="newevent") 966 page.input(name="reset", type="submit", value="Clear selections", id="reset") 967 page.p.close() 968 969 # Show controls for hiding empty days and busy slots. 970 # The positioning of the control, paragraph and table are important here. 971 972 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 973 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 974 975 page.p(class_="controls") 976 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 977 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 978 page.label("Show empty days", for_="showdays", class_="showdays disable") 979 page.label("Hide empty days", for_="showdays", class_="showdays enable") 980 page.p.close() 981 982 freebusy = self.store.get_freebusy(self.user) 983 984 if not freebusy: 985 page.p("No events scheduled.") 986 return 987 988 # Obtain the user's timezone. 989 990 tzid = self.get_tzid() 991 992 # Day view: start at the earliest known day and produce days until the 993 # latest known day, perhaps with expandable sections of empty days. 994 995 # Month view: start at the earliest known month and produce months until 996 # the latest known month, perhaps with expandable sections of empty 997 # months. 998 999 # Details of users to invite to new events could be superimposed on the 1000 # calendar. 1001 1002 # Requests are listed and linked to their tentative positions in the 1003 # calendar. Other participants are also shown. 1004 1005 request_summary = self._get_request_summary() 1006 1007 period_groups = [request_summary, freebusy] 1008 period_group_types = ["request", "freebusy"] 1009 period_group_sources = ["Pending requests", "Your schedule"] 1010 1011 for i, participant in enumerate(participants): 1012 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1013 period_group_types.append("freebusy-part%d" % i) 1014 period_group_sources.append(participant) 1015 1016 groups = [] 1017 group_columns = [] 1018 group_types = period_group_types 1019 group_sources = period_group_sources 1020 all_points = set() 1021 1022 # Obtain time point information for each group of periods. 1023 1024 for periods in period_groups: 1025 periods = convert_periods(periods, tzid) 1026 1027 # Get the time scale with start and end points. 1028 1029 scale = get_scale(periods) 1030 1031 # Get the time slots for the periods. 1032 1033 slots = get_slots(scale) 1034 1035 # Add start of day time points for multi-day periods. 1036 1037 add_day_start_points(slots, tzid) 1038 1039 # Record the slots and all time points employed. 1040 1041 groups.append(slots) 1042 all_points.update([point for point, active in slots]) 1043 1044 # Partition the groups into days. 1045 1046 days = {} 1047 partitioned_groups = [] 1048 partitioned_group_types = [] 1049 partitioned_group_sources = [] 1050 1051 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1052 1053 # Propagate time points to all groups of time slots. 1054 1055 add_slots(slots, all_points) 1056 1057 # Count the number of columns employed by the group. 1058 1059 columns = 0 1060 1061 # Partition the time slots by day. 1062 1063 partitioned = {} 1064 1065 for day, day_slots in partition_by_day(slots).items(): 1066 intervals = [] 1067 last = None 1068 1069 for point, active in day_slots: 1070 columns = max(columns, len(active)) 1071 if last: 1072 intervals.append((last, point)) 1073 last = point 1074 1075 if last: 1076 intervals.append((last, None)) 1077 1078 if not days.has_key(day): 1079 days[day] = set() 1080 1081 # Convert each partition to a mapping from points to active 1082 # periods. 1083 1084 partitioned[day] = dict(day_slots) 1085 1086 # Record the divisions or intervals within each day. 1087 1088 days[day].update(intervals) 1089 1090 if group_type != "request" or columns: 1091 group_columns.append(columns) 1092 partitioned_groups.append(partitioned) 1093 partitioned_group_types.append(group_type) 1094 partitioned_group_sources.append(group_source) 1095 1096 # Add empty days. 1097 1098 add_empty_days(days, tzid) 1099 1100 # Show the controls permitting day selection. 1101 1102 self.show_calendar_day_controls(days) 1103 1104 # Show the calendar itself. 1105 1106 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1107 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1108 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1109 page.table.close() 1110 1111 # End the form region. 1112 1113 page.form.close() 1114 1115 # More page fragment methods. 1116 1117 def show_calendar_day_controls(self, days): 1118 1119 "Show controls for the given 'days' in the calendar." 1120 1121 page = self.page 1122 slots = self.env.get_args().get("slot", []) 1123 1124 for day in days: 1125 value, identifier = self._day_value_and_identifier(day) 1126 self._slot_selector(value, identifier, slots) 1127 1128 # Generate a dynamic stylesheet to allow day selections to colour 1129 # specific days. 1130 # NOTE: The style details need to be coordinated with the static 1131 # NOTE: stylesheet. 1132 1133 page.style(type="text/css") 1134 1135 for day in days: 1136 daystr = format_datetime(day) 1137 page.add("""\ 1138 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1139 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1140 background-color: #5f4; 1141 text-decoration: underline; 1142 } 1143 """ % (daystr, daystr, daystr, daystr)) 1144 1145 page.style.close() 1146 1147 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1148 1149 """ 1150 Show headings for the participants and other scheduling contributors, 1151 defined by 'group_types', 'group_sources' and 'group_columns'. 1152 """ 1153 1154 page = self.page 1155 1156 page.colgroup(span=1, id="columns-timeslot") 1157 1158 for group_type, columns in zip(group_types, group_columns): 1159 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1160 1161 page.thead() 1162 page.tr() 1163 page.th("", class_="emptyheading") 1164 1165 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1166 page.th(source, 1167 class_=(group_type == "request" and "requestheading" or "participantheading"), 1168 colspan=max(columns, 1)) 1169 1170 page.tr.close() 1171 page.thead.close() 1172 1173 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1174 1175 """ 1176 Show calendar days, defined by a collection of 'days', the contributing 1177 period information as 'partitioned_groups' (partitioned by day), the 1178 'partitioned_group_types' indicating the kind of contribution involved, 1179 and the 'group_columns' defining the number of columns in each group. 1180 """ 1181 1182 page = self.page 1183 1184 # Determine the number of columns required. Where participants provide 1185 # no columns for events, one still needs to be provided for the 1186 # participant itself. 1187 1188 all_columns = sum([max(columns, 1) for columns in group_columns]) 1189 1190 # Determine the days providing time slots. 1191 1192 all_days = days.items() 1193 all_days.sort() 1194 1195 # Produce a heading and time points for each day. 1196 1197 for day, intervals in all_days: 1198 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1199 is_empty = True 1200 1201 for slots in groups_for_day: 1202 if not slots: 1203 continue 1204 1205 for active in slots.values(): 1206 if active: 1207 is_empty = False 1208 break 1209 1210 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1211 page.tr() 1212 page.th(class_="dayheading container", colspan=all_columns+1) 1213 self._day_heading(day) 1214 page.th.close() 1215 page.tr.close() 1216 page.thead.close() 1217 1218 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1219 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1220 page.tbody.close() 1221 1222 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1223 1224 """ 1225 Show the time 'intervals' along with period information from the given 1226 'groups', having the indicated 'group_types', each with the number of 1227 columns given by 'group_columns'. 1228 """ 1229 1230 page = self.page 1231 1232 # Obtain the user's timezone. 1233 1234 tzid = self.get_tzid() 1235 1236 # Produce a row for each interval. 1237 1238 intervals = list(intervals) 1239 intervals.sort() 1240 1241 for point, endpoint in intervals: 1242 continuation = point == get_start_of_day(point, tzid) 1243 1244 # Some rows contain no period details and are marked as such. 1245 1246 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1247 1248 css = " ".join( 1249 ["slot"] + 1250 (have_active and ["busy"] or ["empty"]) + 1251 (continuation and ["daystart"] or []) 1252 ) 1253 1254 page.tr(class_=css) 1255 page.th(class_="timeslot") 1256 self._time_point(point, endpoint) 1257 page.th.close() 1258 1259 # Obtain slots for the time point from each group. 1260 1261 for columns, slots, group_type in zip(group_columns, groups, group_types): 1262 active = slots and slots.get(point) 1263 1264 # Where no periods exist for the given time interval, generate 1265 # an empty cell. Where a participant provides no periods at all, 1266 # the colspan is adjusted to be 1, not 0. 1267 1268 if not active: 1269 page.td(class_="empty container", colspan=max(columns, 1)) 1270 self._empty_slot(point, endpoint) 1271 page.td.close() 1272 continue 1273 1274 slots = slots.items() 1275 slots.sort() 1276 spans = get_spans(slots) 1277 1278 empty = 0 1279 1280 # Show a column for each active period. 1281 1282 for t in active: 1283 if t and len(t) >= 2: 1284 1285 # Flush empty slots preceding this one. 1286 1287 if empty: 1288 page.td(class_="empty container", colspan=empty) 1289 self._empty_slot(point, endpoint) 1290 page.td.close() 1291 empty = 0 1292 1293 start, end, uid, key = get_freebusy_details(t) 1294 span = spans[key] 1295 1296 # Produce a table cell only at the start of the period 1297 # or when continued at the start of a day. 1298 1299 if point == start or continuation: 1300 1301 obj = self._get_object(uid) 1302 1303 has_continued = continuation and point != start 1304 will_continue = not ends_on_same_day(point, end, tzid) 1305 is_organiser = obj and obj.get_value("ORGANIZER") == self.user 1306 1307 css = " ".join( 1308 ["event"] + 1309 (has_continued and ["continued"] or []) + 1310 (will_continue and ["continues"] or []) + 1311 (is_organiser and ["organising"] or ["attending"]) 1312 ) 1313 1314 # Only anchor the first cell of events. 1315 1316 if point == start: 1317 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 1318 else: 1319 page.td(class_=css, rowspan=span) 1320 1321 if not obj: 1322 page.span("(Participant is busy)") 1323 else: 1324 summary = obj.get_value("SUMMARY") 1325 1326 # Only link to events if they are not being 1327 # updated by requests. 1328 1329 if uid in self._get_requests() and group_type != "request": 1330 page.span(summary) 1331 else: 1332 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 1333 page.a(summary, href=href) 1334 1335 page.td.close() 1336 else: 1337 empty += 1 1338 1339 # Pad with empty columns. 1340 1341 empty = columns - len(active) 1342 1343 if empty: 1344 page.td(class_="empty container", colspan=empty) 1345 self._empty_slot(point, endpoint) 1346 page.td.close() 1347 1348 page.tr.close() 1349 1350 def _day_heading(self, day): 1351 1352 """ 1353 Generate a heading for 'day' of the following form: 1354 1355 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1356 """ 1357 1358 page = self.page 1359 daystr = format_datetime(day) 1360 value, identifier = self._day_value_and_identifier(day) 1361 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1362 1363 def _time_point(self, point, endpoint): 1364 1365 """ 1366 Generate headings for the 'point' to 'endpoint' period of the following 1367 form: 1368 1369 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1370 <span class="endpoint">10:00:00 CET</span> 1371 """ 1372 1373 page = self.page 1374 tzid = self.get_tzid() 1375 daystr = format_datetime(point.date()) 1376 value, identifier = self._slot_value_and_identifier(point, endpoint) 1377 slots = self.env.get_args().get("slot", []) 1378 self._slot_selector(value, identifier, slots) 1379 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1380 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1381 1382 def _slot_selector(self, value, identifier, slots): 1383 reset = self.env.get_args().has_key("reset") 1384 page = self.page 1385 if not reset and value in slots: 1386 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1387 else: 1388 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1389 1390 def _empty_slot(self, point, endpoint): 1391 page = self.page 1392 value, identifier = self._slot_value_and_identifier(point, endpoint) 1393 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1394 1395 def _day_value_and_identifier(self, day): 1396 value = "%s-" % format_datetime(day) 1397 identifier = "day-%s" % value 1398 return value, identifier 1399 1400 def _slot_value_and_identifier(self, point, endpoint): 1401 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1402 identifier = "slot-%s" % value 1403 return value, identifier 1404 1405 def _show_menu(self, name, default, items): 1406 page = self.page 1407 values = self.env.get_args().get(name, [default]) 1408 page.select(name=name) 1409 for v, label in items: 1410 if v in values: 1411 page.option(label, value=v, selected="selected") 1412 else: 1413 page.option(label, value=v) 1414 page.select.close() 1415 1416 def _show_date_controls(self, name, default, attr, tzid): 1417 1418 """ 1419 Show date controls for a field with the given 'name' and 'default' value 1420 and 'attr', with the given 'tzid' being used if no other time regime 1421 information is provided. 1422 """ 1423 1424 page = self.page 1425 args = self.env.get_args() 1426 1427 event_tzid = attr.get("TZID", tzid) 1428 dt = get_datetime(default, attr) 1429 1430 # Show dates for up to one week around the current date. 1431 1432 base = get_date(dt) 1433 items = [] 1434 for i in range(-7, 8): 1435 d = base + timedelta(i) 1436 items.append((format_datetime(d), self.format_date(d, "full"))) 1437 1438 self._show_menu("%s-date" % name, format_datetime(base), items) 1439 1440 # Show time details. 1441 1442 if isinstance(dt, datetime): 1443 hour = args.get("%s-hour" % name, "%02d" % dt.hour) 1444 minute = args.get("%s-minute" % name, "%02d" % dt.minute) 1445 second = args.get("%s-second" % name, "%02d" % dt.second) 1446 page.add(" ") 1447 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1448 page.add(":") 1449 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1450 page.add(":") 1451 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1452 page.add(" ") 1453 self._show_menu("%s-tzid" % name, event_tzid, 1454 [(event_tzid, event_tzid)] + ( 1455 event_tzid != tzid and [(tzid, tzid)] or [] 1456 )) 1457 1458 # Incoming HTTP request direction. 1459 1460 def select_action(self): 1461 1462 "Select the desired action and show the result." 1463 1464 path_info = self.env.get_path_info().strip("/") 1465 1466 if not path_info: 1467 self.show_calendar() 1468 elif self.show_object(path_info): 1469 pass 1470 else: 1471 self.no_page() 1472 1473 def __call__(self): 1474 1475 "Interpret a request and show an appropriate response." 1476 1477 if not self.user: 1478 self.no_user() 1479 else: 1480 self.select_action() 1481 1482 # Write the headers and actual content. 1483 1484 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1485 print >>self.out 1486 self.out.write(unicode(self.page).encode(self.encoding)) 1487 1488 if __name__ == "__main__": 1489 Manager()() 1490 1491 # vim: tabstop=4 expandtab shiftwidth=4