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