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