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