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