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