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