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