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 self.set_user_locale() 302 303 details = self._get_details(obj) 304 305 # Provide a summary of the object. 306 307 self.page.dl() 308 309 for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]: 310 if name in ["DTSTART", "DTEND"]: 311 value, attr = get_item(details, name) 312 tzid = attr.get("TZID") 313 value = to_timezone(get_datetime(value), tzid).strftime("%c") 314 self.page.dt(name) 315 self.page.dd(value) 316 else: 317 for value in get_values(details, name): 318 self.page.dt(name) 319 self.page.dd(value) 320 321 self.page.dl.close() 322 323 dtstart = format_datetime(get_utc_datetime(details, "DTSTART")) 324 dtend = format_datetime(get_utc_datetime(details, "DTEND")) 325 326 # Indicate whether there are conflicting events. 327 328 freebusy = self.store.get_freebusy(self.user) 329 330 if freebusy: 331 332 # Obtain any time zone details from the suggested event. 333 334 _dtstart, attr = get_item(details, "DTSTART") 335 tzid = attr.get("TZID") 336 337 # Show any conflicts. 338 339 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 340 start, end, found_uid = t[:3] 341 if uid != found_uid: 342 start = to_timezone(get_datetime(start), tzid).strftime("%c") 343 end = to_timezone(get_datetime(end), tzid).strftime("%c") 344 self.page.p("Event conflicts with another from %s to %s." % (start, end)) 345 346 def show_requests_on_page(self): 347 348 "Show requests for the current user." 349 350 # NOTE: This list could be more informative, but it is envisaged that 351 # NOTE: the requests would be visited directly anyway. 352 353 requests = self._get_requests() 354 355 if requests: 356 self.page.p("Pending requests:") 357 358 self.page.ul() 359 360 for request in requests: 361 self.page.li() 362 self.page.a(request, href="%s/%s" % (self.env.get_url().rstrip("/"), request)) 363 self.page.li.close() 364 365 self.page.ul.close() 366 367 else: 368 self.page.p("There are no pending requests.") 369 370 # Full page output methods. 371 372 def show_object(self, path_info): 373 374 "Show an object request using the given 'path_info' for the current user." 375 376 uid = self._get_uid(path_info) 377 obj = self._get_object(uid) 378 379 if not obj: 380 return False 381 382 is_request = uid in self._get_requests() 383 handled = is_request and self.handle_request(uid, obj) 384 385 if handled: 386 return True 387 388 self.new_page(title="Event") 389 390 self.show_object_on_page(uid, obj) 391 392 if is_request and not handled: 393 self.show_request_form() 394 395 return True 396 397 def show_calendar(self): 398 399 "Show the calendar for the current user." 400 401 self.set_user_locale() 402 403 self.new_page(title="Calendar") 404 self.show_requests_on_page() 405 406 freebusy = self.store.get_freebusy(self.user) 407 page = self.page 408 409 if not freebusy: 410 page.p("No events scheduled.") 411 return 412 413 # Set the locale and obtain the user's timezone. 414 415 prefs = self.get_preferences() 416 tzid = prefs.get("TZID") 417 418 # Day view: start at the earliest known day and produce days until the 419 # latest known day, perhaps with expandable sections of empty days. 420 421 # Month view: start at the earliest known month and produce months until 422 # the latest known month, perhaps with expandable sections of empty 423 # months. 424 425 # Details of users to invite to new events could be superimposed on the 426 # calendar. 427 428 # Requests could be listed and linked to their tentative positions in 429 # the calendar. 430 431 slots = get_slots(freebusy) 432 spans = get_spans(slots) 433 434 page.table(border=1, cellspacing=0, cellpadding=5) 435 436 for point, active in slots: 437 dt = to_timezone(get_datetime(point), tzid or "UTC") 438 439 page.tr() 440 page.th(class_="timeslot") 441 page.add(dt.strftime("%c")) 442 page.th.close() 443 444 for t in active: 445 if t: 446 start, end, uid = t[:3] 447 span = spans[uid] 448 if point == start: 449 450 page.td(class_="event", rowspan=span) 451 obj = self._get_object(uid) 452 if obj: 453 details = self._get_details(obj) 454 page.a(get_value(details, "SUMMARY"), href="%s/%s" % (self.env.get_url().rstrip("/"), uid)) 455 page.td.close() 456 else: 457 page.td(class_="empty") 458 page.td.close() 459 460 page.tr.close() 461 462 page.table.close() 463 464 def select_action(self): 465 466 "Select the desired action and show the result." 467 468 path_info = self.env.get_path_info().strip("/") 469 470 if not path_info: 471 self.show_calendar() 472 elif self.show_object(path_info): 473 pass 474 else: 475 self.no_page() 476 477 def __call__(self): 478 479 "Interpret a request and show an appropriate response." 480 481 if not self.user: 482 self.no_user() 483 else: 484 self.select_action() 485 486 # Write the headers and actual content. 487 488 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 489 print >>self.out 490 self.out.write(unicode(self.page).encode(self.encoding)) 491 492 if __name__ == "__main__": 493 Manager()() 494 495 # vim: tabstop=4 expandtab shiftwidth=4