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