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