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.p("Participants for scheduling:") 494 495 for i, participant in enumerate(participants): 496 self.page.p() 497 self.page.input(name="participants", type="text", value=participant) 498 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 499 self.page.p.close() 500 501 self.page.p() 502 self.page.input(name="participants", type="text") 503 self.page.input(name="add-participant", type="submit", value="Add") 504 self.page.p.close() 505 506 self.page.div.close() 507 508 return participants 509 510 # Full page output methods. 511 512 def show_object(self, path_info): 513 514 "Show an object request using the given 'path_info' for the current user." 515 516 uid = self._get_uid(path_info) 517 obj = self._get_object(uid) 518 519 if not obj: 520 return False 521 522 is_request = uid in self._get_requests() 523 handled = self.handle_request(uid, obj, is_request) 524 525 if handled: 526 return True 527 528 self.new_page(title="Event") 529 530 self.show_object_on_page(uid, obj) 531 532 self.show_request_form(obj, is_request and not handled) 533 534 return True 535 536 def show_calendar(self): 537 538 "Show the calendar for the current user." 539 540 self.new_page(title="Calendar") 541 page = self.page 542 543 # Form controls are used in various places on the calendar page. 544 545 page.form(method="POST") 546 547 self.show_requests_on_page() 548 participants = self.show_participants_on_page() 549 550 # Show a button for scheduling a new event. 551 552 page.p() 553 page.input(name="newevent", type="submit", value="New event", id="newevent") 554 page.p.close() 555 556 freebusy = self.store.get_freebusy(self.user) 557 558 if not freebusy: 559 page.p("No events scheduled.") 560 return 561 562 # Obtain the user's timezone. 563 564 prefs = self.get_preferences() 565 tzid = prefs.get("TZID", "UTC") 566 567 # Day view: start at the earliest known day and produce days until the 568 # latest known day, perhaps with expandable sections of empty days. 569 570 # Month view: start at the earliest known month and produce months until 571 # the latest known month, perhaps with expandable sections of empty 572 # months. 573 574 # Details of users to invite to new events could be superimposed on the 575 # calendar. 576 577 # Requests are listed and linked to their tentative positions in the 578 # calendar. Other participants are also shown. 579 580 request_summary = self._get_request_summary() 581 582 period_groups = [request_summary, freebusy] 583 period_group_types = ["request", "freebusy"] 584 period_group_sources = ["Pending requests", "Your schedule"] 585 586 for i, participant in enumerate(participants): 587 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 588 period_group_types.append("freebusy-part%d" % i) 589 period_group_sources.append(participant) 590 591 groups = [] 592 group_columns = [] 593 group_types = period_group_types 594 group_sources = period_group_sources 595 all_points = set() 596 597 # Obtain time point information for each group of periods. 598 599 for periods in period_groups: 600 periods = convert_periods(periods, tzid) 601 602 # Get the time scale with start and end points. 603 604 scale = get_scale(periods) 605 606 # Get the time slots for the periods. 607 608 slots = get_slots(scale) 609 610 # Add start of day time points for multi-day periods. 611 612 add_day_start_points(slots) 613 614 # Record the slots and all time points employed. 615 616 groups.append(slots) 617 all_points.update([point for point, slot in slots]) 618 619 # Partition the groups into days. 620 621 days = {} 622 partitioned_groups = [] 623 partitioned_group_types = [] 624 partitioned_group_sources = [] 625 626 for slots, group_type, group_source in zip(groups, group_types, group_sources): 627 628 # Propagate time points to all groups of time slots. 629 630 add_slots(slots, all_points) 631 632 # Count the number of columns employed by the group. 633 634 columns = 0 635 636 # Partition the time slots by day. 637 638 partitioned = {} 639 640 for day, day_slots in partition_by_day(slots).items(): 641 columns = max(columns, max(map(lambda i: len(i[1]), day_slots))) 642 643 if not days.has_key(day): 644 days[day] = set() 645 646 # Convert each partition to a mapping from points to active 647 # periods. 648 649 day_slots = dict(day_slots) 650 partitioned[day] = day_slots 651 days[day].update(day_slots.keys()) 652 653 if group_type != "request" or columns: 654 group_columns.append(columns) 655 partitioned_groups.append(partitioned) 656 partitioned_group_types.append(group_type) 657 partitioned_group_sources.append(group_source) 658 659 page.table(cellspacing=5, cellpadding=5, id="calendar") 660 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 661 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 662 page.table.close() 663 664 # End the form region. 665 666 page.form.close() 667 668 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 669 670 """ 671 Show headings for the participants and other scheduling contributors, 672 defined by 'group_types', 'group_sources' and 'group_columns'. 673 """ 674 675 page = self.page 676 677 page.colgroup(span=1, id="columns-timeslot") 678 679 for group_type, columns in zip(group_types, group_columns): 680 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 681 682 page.thead() 683 page.tr() 684 page.th("", class_="emptyheading") 685 686 for group_type, source, columns in zip(group_types, group_sources, group_columns): 687 page.th(source, 688 class_=(group_type == "request" and "requestheading" or "participantheading"), 689 colspan=max(columns, 1)) 690 691 page.tr.close() 692 page.thead.close() 693 694 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 695 696 """ 697 Show calendar days, defined by a collection of 'days', the contributing 698 period information as 'partitioned_groups' (partitioned by day), the 699 'partitioned_group_types' indicating the kind of contribution involved, 700 and the 'group_columns' defining the number of columns in each group. 701 """ 702 703 page = self.page 704 705 # Determine the number of columns required. Where participants provide 706 # no columns for events, one still needs to be provided for the 707 # participant itself. 708 709 all_columns = sum([max(columns, 1) for columns in group_columns]) 710 711 # Determine the days providing time slots. 712 713 all_days = days.items() 714 all_days.sort() 715 716 # Produce a heading and time points for each day. 717 718 for day, points in all_days: 719 page.thead() 720 page.tr() 721 page.th(class_="dayheading", colspan=all_columns+1) 722 page.add(self.format_date(day, "full")) 723 page.th.close() 724 page.tr.close() 725 page.thead.close() 726 727 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 728 729 page.tbody() 730 self.show_calendar_points(points, groups_for_day, partitioned_group_types, group_columns) 731 page.tbody.close() 732 733 def show_calendar_points(self, points, groups, group_types, group_columns): 734 735 """ 736 Show the time 'points' along with period information from the given 737 'groups', having the indicated 'group_types', each with the number of 738 columns given by 'group_columns'. 739 """ 740 741 page = self.page 742 743 # Produce a row for each time point. 744 745 points = list(points) 746 points.sort() 747 748 for point in points: 749 continuation = point == get_start_of_day(point) 750 751 page.tr() 752 page.th(class_="timeslot") 753 self._time_point(point) 754 page.th.close() 755 756 # Obtain slots for the time point from each group. 757 758 for columns, slots, group_type in zip(group_columns, groups, group_types): 759 active = slots and slots.get(point) 760 761 # Where no periods exist for the given time interval, generate 762 # an empty cell. Where a participant provides no periods at all, 763 # the colspan is adjusted to be 1, not 0. 764 765 if not active: 766 page.td(class_="empty container", colspan=max(columns, 1)) 767 self._empty_slot(point) 768 page.td.close() 769 continue 770 771 slots = slots.items() 772 slots.sort() 773 spans = get_spans(slots) 774 775 # Show a column for each active period. 776 777 for t in active: 778 if t and len(t) >= 2: 779 start, end, uid, key = get_freebusy_details(t) 780 span = spans[key] 781 782 # Produce a table cell only at the start of the period 783 # or when continued at the start of a day. 784 785 if point == start or continuation: 786 787 has_continued = continuation and point != start 788 will_continue = not ends_on_same_day(point, end) 789 css = " ".join( 790 ["event"] + 791 (has_continued and ["continued"] or []) + 792 (will_continue and ["continues"] or []) 793 ) 794 795 # Only anchor the first cell of events. 796 797 if point == start: 798 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 799 else: 800 page.td(class_=css, rowspan=span) 801 802 obj = self._get_object(uid) 803 804 if not obj: 805 page.span("") 806 else: 807 details = self._get_details(obj) 808 summary = get_value(details, "SUMMARY") 809 810 # Only link to events if they are not being 811 # updated by requests. 812 813 if uid in self._get_requests() and group_type != "request": 814 page.span(summary) 815 else: 816 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 817 page.a(summary, href=href) 818 819 page.td.close() 820 else: 821 page.td(class_="empty container") 822 self._empty_slot(point) 823 page.td.close() 824 825 # Pad with empty columns. 826 827 i = columns - len(active) 828 while i > 0: 829 i -= 1 830 page.td(class_="empty container") 831 self._empty_slot(point) 832 page.td.close() 833 834 page.tr.close() 835 836 def _time_point(self, point): 837 page = self.page 838 pointstr = format_datetime(point) 839 page.input(name="start", type="radio", value=pointstr, id="start-%s" % pointstr, class_="newevent") 840 page.label(self.format_time(point, "long"), class_="timepoint", for_="start-%s" % pointstr) 841 842 def _empty_slot(self, point): 843 page = self.page 844 pointstr = format_datetime(point) 845 page.label("Start a new event at this time", class_="newevent popup", for_="start-%s" % pointstr) 846 847 def select_action(self): 848 849 "Select the desired action and show the result." 850 851 path_info = self.env.get_path_info().strip("/") 852 853 if not path_info: 854 self.show_calendar() 855 elif self.show_object(path_info): 856 pass 857 else: 858 self.no_page() 859 860 def __call__(self): 861 862 "Interpret a request and show an appropriate response." 863 864 if not self.user: 865 self.no_user() 866 else: 867 self.select_action() 868 869 # Write the headers and actual content. 870 871 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 872 print >>self.out 873 self.out.write(unicode(self.page).encode(self.encoding)) 874 875 if __name__ == "__main__": 876 Manager()() 877 878 # vim: tabstop=4 expandtab shiftwidth=4