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