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 self.page.li() 451 self.page.a(request, href="%s/%s" % (self.env.get_url().rstrip("/"), request)) 452 self.page.li.close() 453 454 self.page.ul.close() 455 456 else: 457 self.page.p("There are no pending requests.") 458 459 # Full page output methods. 460 461 def show_object(self, path_info): 462 463 "Show an object request using the given 'path_info' for the current user." 464 465 uid = self._get_uid(path_info) 466 obj = self._get_object(uid) 467 468 if not obj: 469 return False 470 471 is_request = uid in self._get_requests() 472 handled = self.handle_request(uid, obj, is_request) 473 474 if handled: 475 return True 476 477 self.new_page(title="Event") 478 479 self.show_object_on_page(uid, obj) 480 481 self.show_request_form(obj, is_request and not handled) 482 483 return True 484 485 def show_calendar(self): 486 487 "Show the calendar for the current user." 488 489 self.new_page(title="Calendar") 490 page = self.page 491 492 self.show_requests_on_page() 493 494 request_summary = self._get_request_summary() 495 freebusy = self.store.get_freebusy(self.user) 496 497 if not freebusy: 498 page.p("No events scheduled.") 499 return 500 501 # Obtain the user's timezone. 502 503 prefs = self.get_preferences() 504 tzid = prefs.get("TZID", "UTC") 505 506 # Day view: start at the earliest known day and produce days until the 507 # latest known day, perhaps with expandable sections of empty days. 508 509 # Month view: start at the earliest known month and produce months until 510 # the latest known month, perhaps with expandable sections of empty 511 # months. 512 513 # Details of users to invite to new events could be superimposed on the 514 # calendar. 515 516 # Requests could be listed and linked to their tentative positions in 517 # the calendar. 518 519 groups = [] 520 group_columns = [] 521 all_points = set() 522 523 # Obtain time point information for each group of periods. 524 525 for periods in [request_summary, freebusy]: 526 periods = convert_periods(periods, tzid) 527 528 # Get the time scale with start and end points. 529 530 scale = get_scale(periods) 531 532 # Get the time slots for the periods. 533 534 slots = get_slots(scale) 535 536 # Add start of day time points for multi-day periods. 537 538 add_day_start_points(slots) 539 540 # Record the slots and all time points employed. 541 542 groups.append(slots) 543 all_points.update([point for point, slot in slots]) 544 545 # Partition the groups into days. 546 547 days = {} 548 partitioned_groups = [] 549 550 for slots in groups: 551 552 # Propagate time points to all groups of time slots. 553 554 add_slots(slots, all_points) 555 556 # Count the number of columns employed by the group. 557 558 columns = 0 559 560 # Partition the time slots by day. 561 562 partitioned = {} 563 564 for day, day_slots in partition_by_day(slots).items(): 565 columns = max(columns, max(map(lambda i: len(i[1]), day_slots))) 566 567 if not days.has_key(day): 568 days[day] = set() 569 570 # Convert each partition to a mapping from points to active 571 # periods. 572 573 day_slots = dict(day_slots) 574 partitioned[day] = day_slots 575 days[day].update(day_slots.keys()) 576 577 if partitioned: 578 group_columns.append(columns + 1) 579 partitioned_groups.append(partitioned) 580 581 page.table(border=1, cellspacing=0, cellpadding=5) 582 self.show_calendar_days(days, partitioned_groups, group_columns) 583 page.table.close() 584 585 def show_calendar_days(self, days, partitioned_groups, group_columns): 586 page = self.page 587 588 # Determine the number of columns required, the days providing time 589 # slots. 590 591 all_columns = sum(group_columns) 592 all_days = days.items() 593 all_days.sort() 594 595 # Produce a heading and time points for each day. 596 597 for day, points in all_days: 598 page.tr() 599 page.th(class_="dayheading", colspan=all_columns) 600 page.add(self.format_date(day, "full")) 601 page.th.close() 602 page.tr.close() 603 604 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 605 606 self.show_calendar_points(points, groups_for_day, group_columns) 607 608 def show_calendar_points(self, points, groups, group_columns): 609 page = self.page 610 611 # Produce a row for each time point. 612 613 points = list(points) 614 points.sort() 615 616 for point in points: 617 continuation = point == get_start_of_day(point) 618 619 page.tr() 620 page.th(class_="timeslot") 621 page.add(self.format_time(point, "long")) 622 page.th.close() 623 624 # Obtain slots for the time point from each group. 625 626 for columns, slots in zip(group_columns, groups): 627 active = slots and slots.get(point) 628 629 if not active: 630 page.td(class_="empty", colspan=columns) 631 page.td.close() 632 continue 633 634 slots = slots.items() 635 slots.sort() 636 spans = get_spans(slots) 637 638 # Show a column for each active period. 639 640 for t in active: 641 if t: 642 start, end, uid = t[:3] 643 span = spans[uid] 644 if point == start or continuation: 645 646 page.td(class_="event", rowspan=span) 647 obj = self._get_object(uid) 648 if obj: 649 details = self._get_details(obj) 650 page.a(get_value(details, "SUMMARY"), href="%s/%s" % (self.env.get_url().rstrip("/"), uid)) 651 page.td.close() 652 else: 653 page.td(class_="empty") 654 page.td.close() 655 656 page.tr.close() 657 658 def select_action(self): 659 660 "Select the desired action and show the result." 661 662 path_info = self.env.get_path_info().strip("/") 663 664 if not path_info: 665 self.show_calendar() 666 elif self.show_object(path_info): 667 pass 668 else: 669 self.no_page() 670 671 def __call__(self): 672 673 "Interpret a request and show an appropriate response." 674 675 if not self.user: 676 self.no_user() 677 else: 678 self.select_action() 679 680 # Write the headers and actual content. 681 682 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 683 print >>self.out 684 self.out.write(unicode(self.page).encode(self.encoding)) 685 686 if __name__ == "__main__": 687 Manager()() 688 689 # vim: tabstop=4 expandtab shiftwidth=4