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 import babel.dates 28 import cgi, os, sys 29 30 sys.path.append(LIBRARY_PATH) 31 32 from imiptools.content import Handler, get_address, \ 33 get_item, get_uri, get_utc_datetime, get_value, \ 34 get_value_map, get_values, parse_object, to_part 35 from imiptools.dates import format_datetime, get_datetime, get_start_of_day, \ 36 ends_on_same_day, to_timezone 37 from imiptools.mail import Messenger 38 from imiptools.period import add_day_start_points, add_slots, convert_periods, \ 39 get_freebusy_details, \ 40 get_scale, have_conflict, get_slots, get_spans, \ 41 partition_by_day 42 from imiptools.profile import Preferences 43 from vCalendar import to_node 44 import markup 45 import imip_store 46 47 getenv = os.environ.get 48 setenv = os.environ.__setitem__ 49 50 class CGIEnvironment: 51 52 "A CGI-compatible environment." 53 54 def __init__(self): 55 self.args = None 56 self.method = None 57 self.path = None 58 self.path_info = None 59 self.user = None 60 61 def get_args(self): 62 if self.args is None: 63 if self.get_method() != "POST": 64 setenv("QUERY_STRING", "") 65 self.args = cgi.parse(keep_blank_values=True) 66 return self.args 67 68 def get_method(self): 69 if self.method is None: 70 self.method = getenv("REQUEST_METHOD") or "GET" 71 return self.method 72 73 def get_path(self): 74 if self.path is None: 75 self.path = getenv("SCRIPT_NAME") or "" 76 return self.path 77 78 def get_path_info(self): 79 if self.path_info is None: 80 self.path_info = getenv("PATH_INFO") or "" 81 return self.path_info 82 83 def get_user(self): 84 if self.user is None: 85 self.user = getenv("REMOTE_USER") or "" 86 return self.user 87 88 def get_output(self): 89 return sys.stdout 90 91 def get_url(self): 92 path = self.get_path() 93 path_info = self.get_path_info() 94 return "%s%s" % (path.rstrip("/"), path_info) 95 96 def new_url(self, path_info): 97 path = self.get_path() 98 return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/")) 99 100 class ManagerHandler(Handler): 101 102 """ 103 A content handler for use by the manager, as opposed to operating within the 104 mail processing pipeline. 105 """ 106 107 def __init__(self, obj, user, messenger): 108 details, details_attr = obj.values()[0] 109 Handler.__init__(self, details) 110 self.obj = obj 111 self.user = user 112 self.messenger = messenger 113 114 self.organisers = map(get_address, self.get_values("ORGANIZER")) 115 116 # Communication methods. 117 118 def send_message(self, sender): 119 120 """ 121 Create a full calendar object and send it to the organisers, sending a 122 copy to the 'sender'. 123 """ 124 125 node = to_node(self.obj) 126 part = to_part("REPLY", [node]) 127 message = self.messenger.make_outgoing_message([part], self.organisers, outgoing_bcc=sender) 128 self.messenger.sendmail(self.organisers, message.as_string(), outgoing_bcc=sender) 129 130 # Action methods. 131 132 def process_request(self, accept, update=False): 133 134 """ 135 Process the current request for the given 'user', accepting any request 136 when 'accept' is true, declining requests otherwise. Return whether any 137 action was taken. 138 139 If 'update' is given, the sequence number will be incremented in order 140 to override any previous response. 141 """ 142 143 # When accepting or declining, do so only on behalf of this user, 144 # preserving any other attributes set as an attendee. 145 146 for attendee, attendee_attr in self.get_items("ATTENDEE"): 147 148 if attendee == self.user: 149 freebusy = self.store.get_freebusy(attendee) 150 151 attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED" 152 if self.messenger and self.messenger.sender != get_address(attendee): 153 attendee_attr["SENT-BY"] = get_uri(self.messenger.sender) 154 self.details["ATTENDEE"] = [(attendee, attendee_attr)] 155 if update: 156 sequence = self.get_value("SEQUENCE") or "0" 157 self.details["SEQUENCE"] = [(str(int(sequence) + 1), {})] 158 self.update_dtstamp() 159 160 self.send_message(get_address(attendee)) 161 162 return True 163 164 return False 165 166 class Manager: 167 168 "A simple manager application." 169 170 def __init__(self, messenger=None): 171 self.messenger = messenger or Messenger() 172 173 self.env = CGIEnvironment() 174 user = self.env.get_user() 175 self.user = user and get_uri(user) or None 176 self.preferences = None 177 self.locale = None 178 self.requests = None 179 180 self.out = self.env.get_output() 181 self.page = markup.page() 182 self.encoding = "utf-8" 183 184 self.store = imip_store.FileStore() 185 self.objects = {} 186 187 try: 188 self.publisher = imip_store.FilePublisher() 189 except OSError: 190 self.publisher = None 191 192 def _get_uid(self, path_info): 193 return path_info.lstrip("/").split("/", 1)[0] 194 195 def _get_object(self, uid): 196 if self.objects.has_key(uid): 197 return self.objects[uid] 198 199 f = uid and self.store.get_event(self.user, uid) or None 200 201 if not f: 202 return None 203 204 self.objects[uid] = obj = parse_object(f, "utf-8") 205 206 if not obj: 207 return None 208 209 return obj 210 211 def _get_details(self, obj): 212 details, details_attr = obj.values()[0] 213 return details 214 215 def _get_requests(self): 216 if self.requests is None: 217 self.requests = self.store.get_requests(self.user) 218 return self.requests 219 220 def _get_request_summary(self): 221 summary = [] 222 for uid in self._get_requests(): 223 obj = self._get_object(uid) 224 if obj: 225 details = self._get_details(obj) 226 summary.append(( 227 get_value(details, "DTSTART"), 228 get_value(details, "DTEND"), 229 uid 230 )) 231 return summary 232 233 # Preference methods. 234 235 def get_user_locale(self): 236 if not self.locale: 237 self.locale = self.get_preferences().get("LANG", "C") 238 return self.locale 239 240 def get_preferences(self): 241 if not self.preferences: 242 self.preferences = Preferences(self.user) 243 return self.preferences 244 245 # Prettyprinting of dates and times. 246 247 def format_date(self, dt, format): 248 return self._format_datetime(babel.dates.format_date, dt, format) 249 250 def format_time(self, dt, format): 251 return self._format_datetime(babel.dates.format_time, dt, format) 252 253 def format_datetime(self, dt, format): 254 return self._format_datetime(babel.dates.format_datetime, dt, format) 255 256 def _format_datetime(self, fn, dt, format): 257 return fn(dt, format=format, locale=self.get_user_locale()) 258 259 # Data management methods. 260 261 def remove_request(self, uid): 262 return self.store.dequeue_request(self.user, uid) 263 264 # Presentation methods. 265 266 def new_page(self, title): 267 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 268 269 def status(self, code, message): 270 self.header("Status", "%s %s" % (code, message)) 271 272 def header(self, header, value): 273 print >>self.out, "%s: %s" % (header, value) 274 275 def no_user(self): 276 self.status(403, "Forbidden") 277 self.new_page(title="Forbidden") 278 self.page.p("You are not logged in and thus cannot access scheduling requests.") 279 280 def no_page(self): 281 self.status(404, "Not Found") 282 self.new_page(title="Not Found") 283 self.page.p("No page is provided at the given address.") 284 285 def redirect(self, url): 286 self.status(302, "Redirect") 287 self.header("Location", url) 288 self.new_page(title="Redirect") 289 self.page.p("Redirecting to: %s" % url) 290 291 # Request logic and page fragment methods. 292 293 def handle_request(self, uid, request, queued): 294 295 """ 296 Handle actions involving the given 'uid' and 'request' object, where 297 'queued' indicates that the object has not yet been handled. 298 """ 299 300 # Handle a submitted form. 301 302 args = self.env.get_args() 303 handled = True 304 305 accept = args.has_key("accept") 306 decline = args.has_key("decline") 307 update = not queued and args.has_key("update") 308 309 if accept or decline: 310 311 handler = ManagerHandler(request, self.user, self.messenger) 312 313 if handler.process_request(accept, update): 314 315 # Remove the request from the list. 316 317 self.remove_request(uid) 318 319 elif args.has_key("ignore"): 320 321 # Remove the request from the list. 322 323 self.remove_request(uid) 324 325 else: 326 handled = False 327 328 if handled: 329 self.redirect(self.env.get_path()) 330 331 return handled 332 333 def show_request_form(self, obj, needs_action): 334 335 """ 336 Show a form for a request concerning 'obj', indicating whether action is 337 needed if 'needs_action' is specified as a true value. 338 """ 339 340 details = self._get_details(obj) 341 342 attendees = get_value_map(details, "ATTENDEE") 343 attendee_attr = attendees.get(self.user) 344 345 if attendee_attr: 346 partstat = attendee_attr.get("PARTSTAT") 347 if partstat == "ACCEPTED": 348 self.page.p("This request has been accepted.") 349 elif partstat == "DECLINED": 350 self.page.p("This request has been declined.") 351 else: 352 self.page.p("This request has been ignored.") 353 354 if needs_action: 355 self.page.p("An action is required for this request:") 356 else: 357 self.page.p("This request can be updated as follows:") 358 359 self.page.form(method="POST") 360 self.page.p() 361 self.page.input(name="accept", type="submit", value="Accept") 362 self.page.add(" ") 363 self.page.input(name="decline", type="submit", value="Decline") 364 self.page.add(" ") 365 self.page.input(name="ignore", type="submit", value="Ignore") 366 if not needs_action: 367 self.page.input(name="update", type="hidden", value="true") 368 self.page.p.close() 369 self.page.form.close() 370 371 def show_object_on_page(self, uid, obj): 372 373 """ 374 Show the calendar object with the given 'uid' and representation 'obj' 375 on the current page. 376 """ 377 378 # Obtain the user's timezone. 379 380 prefs = self.get_preferences() 381 tzid = prefs.get("TZID", "UTC") 382 383 # Provide a summary of the object. 384 385 details = self._get_details(obj) 386 387 self.page.dl() 388 389 for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]: 390 if name in ["DTSTART", "DTEND"]: 391 value, attr = get_item(details, name) 392 tzid = attr.get("TZID", tzid) 393 value = self.format_datetime(to_timezone(get_datetime(value), tzid), "full") 394 self.page.dt(name) 395 self.page.dd(value) 396 else: 397 for value in get_values(details, name): 398 self.page.dt(name) 399 self.page.dd(value) 400 401 self.page.dl.close() 402 403 dtstart = format_datetime(get_utc_datetime(details, "DTSTART")) 404 dtend = format_datetime(get_utc_datetime(details, "DTEND")) 405 406 # Indicate whether there are conflicting events. 407 408 freebusy = self.store.get_freebusy(self.user) 409 410 if freebusy: 411 412 # Obtain any time zone details from the suggested event. 413 414 _dtstart, attr = get_item(details, "DTSTART") 415 tzid = attr.get("TZID", tzid) 416 417 # Show any conflicts. 418 419 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 420 start, end, found_uid = t[:3] 421 422 # Provide details of any conflicting event. 423 424 if uid != found_uid: 425 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 426 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 427 self.page.p("Event conflicts with another from %s to %s: " % (start, end)) 428 429 # Show the event summary for the conflicting event. 430 431 found_obj = self._get_object(found_uid) 432 if found_obj: 433 found_details = self._get_details(found_obj) 434 self.page.a(get_value(found_details, "SUMMARY"), href=self.env.new_url(found_uid)) 435 436 def show_requests_on_page(self): 437 438 "Show requests for the current user." 439 440 # NOTE: This list could be more informative, but it is envisaged that 441 # NOTE: the requests would be visited directly anyway. 442 443 requests = self._get_requests() 444 445 self.page.div(id="pending-requests") 446 447 if requests: 448 self.page.p("Pending requests:") 449 450 self.page.ul() 451 452 for request in requests: 453 obj = self._get_object(request) 454 if obj: 455 details = self._get_details(obj) 456 self.page.li() 457 self.page.a(get_value(details, "SUMMARY"), href="#request-%s" % request) 458 self.page.li.close() 459 460 self.page.ul.close() 461 462 else: 463 self.page.p("There are no pending requests.") 464 465 self.page.div.close() 466 467 def show_participants_on_page(self): 468 469 "Show participants for scheduling purposes." 470 471 args = self.env.get_args() 472 participants = args.get("participants", []) 473 474 try: 475 for name, value in args.items(): 476 if name.startswith("remove-participant-"): 477 i = int(name[len("remove-participant-"):]) 478 del participants[i] 479 break 480 except ValueError: 481 pass 482 483 # Trim empty participants. 484 485 while participants and not participants[-1].strip(): 486 participants.pop() 487 488 # Show any specified participants together with controls to remove and 489 # add participants. 490 491 self.page.div(id="participants") 492 493 self.page.form(method="POST") 494 495 self.page.p("Participants for scheduling:") 496 497 for i, participant in enumerate(participants): 498 self.page.p() 499 self.page.input(name="participants", type="text", value=participant) 500 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 501 self.page.p.close() 502 503 self.page.p() 504 self.page.input(name="participants", type="text") 505 self.page.input(name="add-participant", type="submit", value="Add") 506 self.page.p.close() 507 508 self.page.form.close() 509 510 self.page.div.close() 511 512 return participants 513 514 # Full page output methods. 515 516 def show_object(self, path_info): 517 518 "Show an object request using the given 'path_info' for the current user." 519 520 uid = self._get_uid(path_info) 521 obj = self._get_object(uid) 522 523 if not obj: 524 return False 525 526 is_request = uid in self._get_requests() 527 handled = self.handle_request(uid, obj, is_request) 528 529 if handled: 530 return True 531 532 self.new_page(title="Event") 533 534 self.show_object_on_page(uid, obj) 535 536 self.show_request_form(obj, is_request and not handled) 537 538 return True 539 540 def show_calendar(self): 541 542 "Show the calendar for the current user." 543 544 self.new_page(title="Calendar") 545 page = self.page 546 547 self.show_requests_on_page() 548 participants = self.show_participants_on_page() 549 550 freebusy = self.store.get_freebusy(self.user) 551 552 if not freebusy: 553 page.p("No events scheduled.") 554 return 555 556 # Obtain the user's timezone. 557 558 prefs = self.get_preferences() 559 tzid = prefs.get("TZID", "UTC") 560 561 # Day view: start at the earliest known day and produce days until the 562 # latest known day, perhaps with expandable sections of empty days. 563 564 # Month view: start at the earliest known month and produce months until 565 # the latest known month, perhaps with expandable sections of empty 566 # months. 567 568 # Details of users to invite to new events could be superimposed on the 569 # calendar. 570 571 # Requests are listed and linked to their tentative positions in the 572 # calendar. Other participants are also shown. 573 574 request_summary = self._get_request_summary() 575 576 period_groups = [request_summary, freebusy] 577 period_group_types = ["request", "freebusy"] 578 period_group_sources = ["Pending requests", "Your schedule"] 579 580 for i, participant in enumerate(participants): 581 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 582 period_group_types.append("freebusy-part%d" % i) 583 period_group_sources.append(participant) 584 585 groups = [] 586 group_columns = [] 587 group_types = period_group_types 588 group_sources = period_group_sources 589 all_points = set() 590 591 # Obtain time point information for each group of periods. 592 593 for periods in period_groups: 594 periods = convert_periods(periods, tzid) 595 596 # Get the time scale with start and end points. 597 598 scale = get_scale(periods) 599 600 # Get the time slots for the periods. 601 602 slots = get_slots(scale) 603 604 # Add start of day time points for multi-day periods. 605 606 add_day_start_points(slots) 607 608 # Record the slots and all time points employed. 609 610 groups.append(slots) 611 all_points.update([point for point, slot in slots]) 612 613 # Partition the groups into days. 614 615 days = {} 616 partitioned_groups = [] 617 partitioned_group_types = [] 618 partitioned_group_sources = [] 619 620 for slots, group_type, group_source in zip(groups, group_types, group_sources): 621 622 # Propagate time points to all groups of time slots. 623 624 add_slots(slots, all_points) 625 626 # Count the number of columns employed by the group. 627 628 columns = 0 629 630 # Partition the time slots by day. 631 632 partitioned = {} 633 634 for day, day_slots in partition_by_day(slots).items(): 635 columns = max(columns, max(map(lambda i: len(i[1]), day_slots))) 636 637 if not days.has_key(day): 638 days[day] = set() 639 640 # Convert each partition to a mapping from points to active 641 # periods. 642 643 day_slots = dict(day_slots) 644 partitioned[day] = day_slots 645 days[day].update(day_slots.keys()) 646 647 if group_type != "request" or columns: 648 group_columns.append(columns) 649 partitioned_groups.append(partitioned) 650 partitioned_group_types.append(group_type) 651 partitioned_group_sources.append(group_source) 652 653 page.table(cellspacing=5, cellpadding=5, id="calendar") 654 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 655 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 656 page.table.close() 657 658 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 659 660 """ 661 Show headings for the participants and other scheduling contributors, 662 defined by 'group_types', 'group_sources' and 'group_columns'. 663 """ 664 665 page = self.page 666 667 page.colgroup(span=1, id="columns-timeslot") 668 669 for group_type, columns in zip(group_types, group_columns): 670 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 671 672 page.thead() 673 page.tr() 674 page.th("", class_="emptyheading") 675 676 for group_type, source, columns in zip(group_types, group_sources, group_columns): 677 page.th(source, 678 class_=(group_type == "request" and "requestheading" or "participantheading"), 679 colspan=max(columns, 1)) 680 681 page.tr.close() 682 page.thead.close() 683 684 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 685 686 """ 687 Show calendar days, defined by a collection of 'days', the contributing 688 period information as 'partitioned_groups' (partitioned by day), the 689 'partitioned_group_types' indicating the kind of contribution involved, 690 and the 'group_columns' defining the number of columns in each group. 691 """ 692 693 page = self.page 694 695 # Determine the number of columns required. Where participants provide 696 # no columns for events, one still needs to be provided for the 697 # participant itself. 698 699 all_columns = sum([max(columns, 1) for columns in group_columns]) 700 701 # Determine the days providing time slots. 702 703 all_days = days.items() 704 all_days.sort() 705 706 # Produce a heading and time points for each day. 707 708 for day, points in all_days: 709 page.thead() 710 page.tr() 711 page.th(class_="dayheading", colspan=all_columns+1) 712 page.add(self.format_date(day, "full")) 713 page.th.close() 714 page.tr.close() 715 page.thead.close() 716 717 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 718 719 page.tbody() 720 self.show_calendar_points(points, groups_for_day, partitioned_group_types, group_columns) 721 page.tbody.close() 722 723 def show_calendar_points(self, points, groups, group_types, group_columns): 724 725 """ 726 Show the time 'points' along with period information from the given 727 'groups', having the indicated 'group_types', each with the number of 728 columns given by 'group_columns'. 729 """ 730 731 page = self.page 732 733 # Produce a row for each time point. 734 735 points = list(points) 736 points.sort() 737 738 for point in points: 739 continuation = point == get_start_of_day(point) 740 741 page.tr() 742 page.th(class_="timeslot") 743 page.add(self.format_time(point, "long")) 744 page.th.close() 745 746 # Obtain slots for the time point from each group. 747 748 for columns, slots, group_type in zip(group_columns, groups, group_types): 749 active = slots and slots.get(point) 750 751 # Where no periods exist for the given time interval, generate 752 # an empty cell. Where a participant provides no periods at all, 753 # the colspan is adjusted to be 1, not 0. 754 755 if not active: 756 page.td("", class_="empty", colspan=max(columns, 1)) 757 continue 758 759 slots = slots.items() 760 slots.sort() 761 spans = get_spans(slots) 762 763 # Show a column for each active period. 764 765 for t in active: 766 if t and len(t) >= 2: 767 start, end, uid, key = get_freebusy_details(t) 768 span = spans[key] 769 770 # Produce a table cell only at the start of the period 771 # or when continued at the start of a day. 772 773 if point == start or continuation: 774 775 has_continued = continuation and point != start 776 will_continue = not ends_on_same_day(point, end) 777 css = " ".join( 778 ["event"] + 779 (has_continued and ["continued"] or []) + 780 (will_continue and ["continues"] or []) 781 ) 782 783 # Only anchor the first cell of events. 784 785 if point == start: 786 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 787 else: 788 page.td(class_=css, rowspan=span) 789 790 obj = self._get_object(uid) 791 792 if not obj: 793 page.span("") 794 else: 795 details = self._get_details(obj) 796 summary = get_value(details, "SUMMARY") 797 798 # Only link to events if they are not being 799 # updated by requests. 800 801 if uid in self._get_requests() and group_type != "request": 802 page.span(summary) 803 else: 804 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 805 page.a(summary, href=href) 806 807 page.td.close() 808 else: 809 page.td("", class_="empty") 810 811 # Pad with empty columns. 812 813 i = columns - len(active) 814 while i > 0: 815 i -= 1 816 page.td("", class_="empty") 817 818 page.tr.close() 819 820 def select_action(self): 821 822 "Select the desired action and show the result." 823 824 path_info = self.env.get_path_info().strip("/") 825 826 if not path_info: 827 self.show_calendar() 828 elif self.show_object(path_info): 829 pass 830 else: 831 self.no_page() 832 833 def __call__(self): 834 835 "Interpret a request and show an appropriate response." 836 837 if not self.user: 838 self.no_user() 839 else: 840 self.select_action() 841 842 # Write the headers and actual content. 843 844 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 845 print >>self.out 846 self.out.write(unicode(self.page).encode(self.encoding)) 847 848 if __name__ == "__main__": 849 Manager()() 850 851 # vim: tabstop=4 expandtab shiftwidth=4