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 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) 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 participant in participants: 581 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 582 period_group_types.append("freebusy") 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 partitioned: 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(border=1, cellspacing=0, cellpadding=5) 654 self.show_calendar_participant_headings(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_sources, group_columns): 659 660 """ 661 Show headings for the participants and other scheduling contributors, 662 defined by 'group_sources' and 'group_columns'. 663 """ 664 665 page = self.page 666 667 page.colgroup(span=1) # for datetime information 668 669 for columns in group_columns: 670 page.colgroup(span=columns) 671 672 page.thead() 673 page.tr() 674 page.th("", class_="emptyheading") 675 676 for source, columns in zip(group_sources, group_columns): 677 page.th(source, class_="participantheading", colspan=columns) 678 679 page.tr.close() 680 page.thead.close() 681 682 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 683 684 """ 685 Show calendar days, defined by a collection of 'days', the contributing 686 period information as 'partitioned_groups' (partitioned by day), the 687 'partitioned_group_types' indicating the kind of contribution involved, 688 and the 'group_columns' defining the number of columns in each group. 689 """ 690 691 page = self.page 692 693 # Determine the number of columns required, the days providing time 694 # slots. 695 696 all_columns = sum(group_columns) 697 all_days = days.items() 698 all_days.sort() 699 700 # Produce a heading and time points for each day. 701 702 for day, points in all_days: 703 page.thead() 704 page.tr() 705 page.th(class_="dayheading", colspan=all_columns+1) 706 page.add(self.format_date(day, "full")) 707 page.th.close() 708 page.tr.close() 709 page.thead.close() 710 711 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 712 713 page.tbody() 714 self.show_calendar_points(points, groups_for_day, partitioned_group_types, group_columns) 715 page.tbody.close() 716 717 def show_calendar_points(self, points, groups, group_types, group_columns): 718 719 """ 720 Show the time 'points' along with period information from the given 721 'groups', having the indicated 'group_types', each with the number of 722 columns given by 'group_columns'. 723 """ 724 725 page = self.page 726 727 # Produce a row for each time point. 728 729 points = list(points) 730 points.sort() 731 732 for point in points: 733 continuation = point == get_start_of_day(point) 734 735 page.tr() 736 page.th(class_="timeslot") 737 page.add(self.format_time(point, "long")) 738 page.th.close() 739 740 # Obtain slots for the time point from each group. 741 742 for columns, slots, group_type in zip(group_columns, groups, group_types): 743 active = slots and slots.get(point) 744 745 if not active: 746 page.td(class_="empty", colspan=columns) 747 page.td.close() 748 continue 749 750 slots = slots.items() 751 slots.sort() 752 spans = get_spans(slots) 753 754 # Show a column for each active period. 755 756 for t in active: 757 if t and len(t) >= 2: 758 start, end, uid, key = get_freebusy_details(t) 759 span = spans[key] 760 761 # Produce a table cell only at the start of the period 762 # or when continued at the start of a day. 763 764 if point == start or continuation: 765 766 page.td(class_="event", rowspan=span) 767 768 obj = self._get_object(uid) 769 770 if not obj: 771 page.span("") 772 else: 773 details = self._get_details(obj) 774 summary = get_value(details, "SUMMARY") 775 776 # Only link to events if they are not being 777 # updated by requests. 778 779 if uid in self._get_requests() and group_type != "request": 780 page.span(summary, id="%s-%s" % (group_type, uid)) 781 else: 782 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 783 784 # Only anchor the first cell of events. 785 786 if point == start: 787 page.a(summary, href=href, id="%s-%s" % (group_type, uid)) 788 else: 789 page.a(summary, href=href) 790 791 page.td.close() 792 else: 793 page.td(class_="empty") 794 page.td.close() 795 796 # Pad with empty columns. 797 798 i = columns - len(active) 799 while i > 0: 800 i -= 1 801 page.td(class_="empty") 802 page.td.close() 803 804 page.tr.close() 805 806 def select_action(self): 807 808 "Select the desired action and show the result." 809 810 path_info = self.env.get_path_info().strip("/") 811 812 if not path_info: 813 self.show_calendar() 814 elif self.show_object(path_info): 815 pass 816 else: 817 self.no_page() 818 819 def __call__(self): 820 821 "Interpret a request and show an appropriate response." 822 823 if not self.user: 824 self.no_user() 825 else: 826 self.select_action() 827 828 # Write the headers and actual content. 829 830 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 831 print >>self.out 832 self.out.write(unicode(self.page).encode(self.encoding)) 833 834 if __name__ == "__main__": 835 Manager()() 836 837 # vim: tabstop=4 expandtab shiftwidth=4