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