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