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