1 #!/usr/bin/env python 2 3 """ 4 Common resource functionality for Web calendar clients. 5 6 Copyright (C) 2014, 2015, 2016, 2017 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 from datetime import date, datetime, timedelta 23 from imiptools.client import Client, ClientForObject 24 from imiptools.data import get_uri 25 from imiptools.dates import format_datetime, to_date 26 from imiptools.freebusy import FreeBusyCollection 27 from imipweb.data import event_period_from_period, form_period_from_period, \ 28 PeriodError 29 from imipweb.env import CGIEnvironment 30 from urllib import urlencode 31 import babel.dates 32 import hashlib, hmac 33 import markup 34 import pytz 35 import time 36 37 class Resource: 38 39 "A Web application resource." 40 41 def __init__(self, resource=None): 42 43 """ 44 Initialise a resource, allowing it to share the environment of any given 45 existing 'resource'. 46 """ 47 48 self.encoding = "utf-8" 49 self.env = CGIEnvironment(self.encoding) 50 51 self.objects = {} 52 self.locale = None 53 self.requests = None 54 55 self.out = resource and resource.out or self.env.get_output() 56 self.page = resource and resource.page or markup.page() 57 self.html_ids = None 58 59 # Computed state. 60 61 self.state = {} 62 63 # Presentation methods. 64 65 def new_page(self, title): 66 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 67 self.html_ids = set() 68 69 def status(self, code, message): 70 self.header("Status", "%s %s" % (code, message)) 71 72 def header(self, header, value): 73 print >>self.out, "%s: %s" % (header, value) 74 75 def no_user(self): 76 self.status(403, "Forbidden") 77 self.new_page(title="Forbidden") 78 self.page.p("You are not logged in and thus cannot access scheduling requests.") 79 80 def no_page(self): 81 self.status(404, "Not Found") 82 self.new_page(title="Not Found") 83 self.page.p("No page is provided at the given address.") 84 85 def redirect(self, url): 86 self.status(302, "Redirect") 87 self.header("Location", url) 88 self.new_page(title="Redirect") 89 self.page.p("Redirecting to: %s" % url) 90 91 def link_to(self, uid=None, recurrenceid=None, args=None): 92 93 """ 94 Return a link to a resource, being an object with any given 'uid' and 95 'recurrenceid', or the main resource otherwise. 96 97 See get_identifiers for the decoding of such links. 98 99 If 'args' is specified, the given dictionary is encoded and included. 100 """ 101 102 path = [] 103 if uid: 104 path.append(uid) 105 if recurrenceid: 106 path.append(recurrenceid) 107 return "%s%s" % (self.env.new_url("/".join(path)), args and ("?%s" % urlencode(args)) or "") 108 109 # Access to objects. 110 111 def get_identifiers(self, path_info): 112 113 """ 114 Return identifiers provided by 'path_info', potentially encoded by 115 'link_to'. 116 """ 117 118 parts = path_info.lstrip("/").split("/") 119 120 # UID only. 121 122 if len(parts) == 1: 123 return parts[0], None 124 125 # UID and RECURRENCE-ID. 126 127 else: 128 return parts[:2] 129 130 def _get_object(self, uid, recurrenceid=None, section=None, username=None): 131 if self.objects.has_key((uid, recurrenceid, section, username)): 132 return self.objects[(uid, recurrenceid, section, username)] 133 134 obj = self.objects[(uid, recurrenceid, section, username)] = self.get_stored_object(uid, recurrenceid, section, username) 135 return obj 136 137 def _get_recurrences(self, uid): 138 return self.store.get_recurrences(self.user, uid) 139 140 def _get_active_recurrences(self, uid): 141 return self.store.get_active_recurrences(self.user, uid) 142 143 def _get_requests(self): 144 if self.requests is None: 145 self.requests = self.store.get_requests(self.user) 146 return self.requests 147 148 def _have_request(self, uid, recurrenceid=None, type=None, strict=False): 149 return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict) 150 151 def _is_request(self): 152 return self._have_request(self.uid, self.recurrenceid) 153 154 def _get_counters(self, uid, recurrenceid=None): 155 return self.store.get_counters(self.user, uid, recurrenceid) 156 157 def _get_request_summary(self, view_period): 158 159 """ 160 Return a list of periods comprising the request summary within the given 161 'view_period'. 162 """ 163 164 summary = FreeBusyCollection() 165 166 for uid, recurrenceid, request_type in self._get_requests(): 167 168 # Obtain either normal objects or counter-proposals. 169 170 if not request_type: 171 objs = [self._get_object(uid, recurrenceid)] 172 elif request_type == "COUNTER": 173 objs = [] 174 for attendee in self.store.get_counters(self.user, uid, recurrenceid): 175 objs.append(self._get_object(uid, recurrenceid, "counters", attendee)) 176 177 # For each object, obtain the periods involved. 178 179 for obj in objs: 180 if obj: 181 recurrenceids = self._get_recurrences(uid) 182 183 # Obtain only active periods, not those replaced by redefined 184 # recurrences, converting to free/busy periods. 185 186 for p in obj.get_active_periods(recurrenceids, self.get_tzid(), 187 start=view_period.get_start(), end=view_period.get_end()): 188 189 summary.append(obj.get_freebusy_period(p)) 190 191 return summary 192 193 # Preference methods. 194 195 def get_user_locale(self): 196 if not self.locale: 197 self.locale = self.get_preferences().get("LANG", "en", True) or "en" 198 return self.locale 199 200 # Prettyprinting of dates and times. 201 202 def format_date(self, dt, format): 203 return self._format_datetime(babel.dates.format_date, dt, format) 204 205 def format_time(self, dt, format): 206 return self._format_datetime(babel.dates.format_time, dt, format) 207 208 def format_datetime(self, dt, format): 209 return self._format_datetime( 210 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 211 dt, format) 212 213 def _format_datetime(self, fn, dt, format): 214 return fn(dt, format=format, locale=self.get_user_locale()) 215 216 class ResourceClient(Resource, Client): 217 218 "A Web application resource and calendar client." 219 220 def __init__(self, resource=None): 221 Resource.__init__(self, resource) 222 user = self.env.get_user() 223 Client.__init__(self, user and get_uri(user) or None) 224 225 class ResourceClientForObject(Resource, ClientForObject): 226 227 "A Web application resource and calendar client for a specific object." 228 229 def __init__(self, resource=None, messenger=None): 230 Resource.__init__(self, resource) 231 user = self.env.get_user() 232 ClientForObject.__init__(self, None, user and get_uri(user) or None, messenger) 233 234 class FormUtilities: 235 236 "Utility methods resource mix-in." 237 238 def get_validation_token(self, details=None): 239 240 "Return a token suitable for validating a form submission." 241 242 # Use a secret held in the user's preferences. 243 244 prefs = self.get_preferences() 245 if not prefs.has_key("secret"): 246 prefs["secret"] = str(time.time()) 247 248 # Combine it with the user identity and any supplied details. 249 250 secret = prefs["secret"].encode("utf-8") 251 details = u"".join([self.env.get_user()] + (details or [])).encode("utf-8") 252 253 return hmac.new(secret, details, hashlib.sha256).hexdigest() 254 255 def check_validation_token(self, name="token", details=None): 256 257 """ 258 Check the field having the given 'name', returning if its value matches 259 the validation token generated using any given 'details'. 260 """ 261 262 return self.env.get_args().get(name, [None])[0] == self.get_validation_token(details) 263 264 def validator(self, name="token", details=None): 265 266 """ 267 Show a control having the given 'name' that is used to validate form 268 submissions, employing any additional 'details' in the construction of 269 the validation token. 270 """ 271 272 self.page.input(name=name, type="hidden", value=self.get_validation_token(details)) 273 274 def prefixed_args(self, prefix, convert=None): 275 276 """ 277 Return values for all arguments having the given 'prefix' in their 278 names, removing the prefix to obtain each value from the argument name 279 itself. The 'convert' callable can be specified to perform a conversion 280 (to int, for example). 281 """ 282 283 args = self.env.get_args() 284 285 values = [] 286 for name in args.keys(): 287 if name.startswith(prefix): 288 value = name[len(prefix):] 289 if convert: 290 try: 291 value = convert(value) 292 except ValueError: 293 pass 294 values.append(value) 295 return values 296 297 def control(self, name, type, value, selected=False, **kw): 298 299 """ 300 Show a control with the given 'name', 'type' and 'value', with 301 'selected' indicating whether it should be selected (checked or 302 equivalent), and with keyword arguments setting other properties. 303 """ 304 305 page = self.page 306 if type in ("checkbox", "radio") and selected: 307 page.input(name=name, type=type, value=value, checked="checked", **kw) 308 else: 309 page.input(name=name, type=type, value=value, **kw) 310 311 def menu(self, name, default, items, values=None, class_=""): 312 313 """ 314 Show a select menu having the given 'name', set to the given 'default', 315 providing the given (value, label) 'items', selecting the given 'values' 316 (or using the request parameters if not specified), and employing the 317 given CSS 'class_' if specified. 318 """ 319 320 page = self.page 321 values = values or [default] 322 323 page.select(name=name, class_=class_) 324 for v, label in items: 325 if v is None: 326 continue 327 if v in values: 328 page.option(label, value=v, selected="selected") 329 else: 330 page.option(label, value=v) 331 page.select.close() 332 333 def date_controls(self, name, default, show_tzid=True, read_only=False): 334 335 """ 336 Show date controls for a field with the given 'name' and 'default' form 337 date value. 338 339 If 'show_tzid' is set to a false value, the time zone menu will not be 340 provided. 341 342 If 'read_only' is set to a true value, the controls will be hidden and 343 labels will be employed instead. 344 """ 345 346 page = self.page 347 348 # Show dates for up to one week around the current date. 349 350 page.span(class_="date enabled") 351 352 dt = default.as_datetime() 353 354 # For invalid datetimes, try to get a date instead. 355 356 if not dt: 357 dt = default.as_datetime(with_time=False) 358 359 # For invalid dates, just use today's date. 360 361 if not dt: 362 dt = date.today() 363 364 base = to_date(dt) 365 366 # Show a date label with a hidden field if read-only. 367 368 if read_only: 369 self.control("%s-date" % name, "hidden", format_datetime(base)) 370 page.span(self.format_date(base, "long")) 371 372 # Show dates for up to one week around the current date. 373 # NOTE: Support paging to other dates. 374 375 else: 376 items = [] 377 for i in range(-7, 8): 378 d = base + timedelta(i) 379 items.append((format_datetime(d), self.format_date(d, "full"))) 380 self.menu("%s-date" % name, format_datetime(base), items) 381 382 page.span.close() 383 384 # Show time details. 385 386 page.span(class_="time enabled") 387 388 if read_only: 389 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 390 self.control("%s-hour" % name, "hidden", default.get_hour()) 391 self.control("%s-minute" % name, "hidden", default.get_minute()) 392 self.control("%s-second" % name, "hidden", default.get_second()) 393 else: 394 self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 395 page.add(":") 396 self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 397 page.add(":") 398 self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 399 400 # Show time zone details. 401 402 if show_tzid: 403 page.add(" ") 404 tzid = default.get_tzid() or self.get_tzid() 405 406 # Show a label if read-only or a menu otherwise. 407 408 if read_only: 409 self.control("%s-tzid" % name, "hidden", tzid) 410 page.span(tzid) 411 else: 412 self.timezone_menu("%s-tzid" % name, tzid) 413 414 page.span.close() 415 416 def timezone_menu(self, name, default): 417 418 """ 419 Show timezone controls using a menu with the given 'name', set to the 420 given 'default' unless a field of the given 'name' provides a value. 421 """ 422 423 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 424 self.menu(name, default, entries) 425 426 class DateTimeFormUtilities: 427 428 "Date/time control methods resource mix-in." 429 430 # Control naming helpers. 431 432 def element_identifier(self, name, index=None): 433 return index is not None and "%s-%d" % (name, index) or name 434 435 def element_name(self, name, suffix, index=None): 436 return index is not None and "%s-%s" % (name, suffix) or name 437 438 def element_enable(self, index=None): 439 return str(index or 0) 440 441 def show_object_datetime_controls(self, period, index=None): 442 443 """ 444 Show datetime-related controls if already active or if an object needs 445 them for the given 'period'. The given 'index' is used to parameterise 446 individual controls for dynamic manipulation. 447 """ 448 449 p = form_period_from_period(period) 450 451 page = self.page 452 _id = self.element_identifier 453 _name = self.element_name 454 _enable = self.element_enable 455 456 # Add a dynamic stylesheet to permit the controls to modify the display. 457 # NOTE: The style details need to be coordinated with the static 458 # NOTE: stylesheet. 459 460 if index is not None: 461 page.style(type="text/css") 462 463 # Unlike the rules for object properties, these affect recurrence 464 # properties. 465 466 page.add("""\ 467 input#dttimes-enable-%(index)d, 468 input#dtend-enable-%(index)d, 469 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 470 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 471 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 472 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 473 display: none; 474 } 475 476 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .date.enabled, 477 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .date.disabled { 478 visibility: hidden; 479 }""" % {"index" : index}) 480 481 page.style.close() 482 483 self.control( 484 _name("dtend-control", "recur", index), "checkbox", 485 _enable(index), p.end_enabled, 486 id=_id("dtend-enable", index) 487 ) 488 489 self.control( 490 _name("dttimes-control", "recur", index), "checkbox", 491 _enable(index), p.times_enabled, 492 id=_id("dttimes-enable", index) 493 ) 494 495 def show_datetime_controls(self, formdate, show_start): 496 497 """ 498 Show datetime details from the current object for the 'formdate', 499 showing start details if 'show_start' is set to a true value. Details 500 will appear as controls for organisers and labels for attendees. 501 """ 502 503 page = self.page 504 505 # Show controls for editing. 506 507 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 508 509 if show_start: 510 page.div(class_="dt enabled") 511 self.date_controls("dtstart", formdate) 512 page.br() 513 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 514 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 515 page.div.close() 516 517 else: 518 self.date_controls("dtend", formdate) 519 page.div(class_="dt disabled") 520 page.label("Specify end date", for_="dtend-enable", class_="enable") 521 page.div.close() 522 page.div(class_="dt enabled") 523 page.label("End on same day", for_="dtend-enable", class_="disable") 524 page.div.close() 525 526 page.td.close() 527 528 def show_recurrence_controls(self, index, period, recurrenceid, show_start): 529 530 """ 531 Show datetime details from the current object for the recurrence having 532 the given 'index', with the recurrence period described by 'period', 533 indicating a start, end and origin of the period from the event details, 534 employing any 'recurrenceid' for the object to configure the displayed 535 information. 536 537 If 'show_start' is set to a true value, the start details will be shown; 538 otherwise, the end details will be shown. 539 """ 540 541 page = self.page 542 _id = self.element_identifier 543 _name = self.element_name 544 545 period = form_period_from_period(period) 546 547 # Show controls for editing. 548 549 if not period.replaced: 550 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 551 552 read_only = period.origin == "RRULE" 553 554 if show_start: 555 page.div(class_="dt enabled") 556 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), read_only=read_only) 557 if not read_only: 558 page.br() 559 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 560 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 561 page.div.close() 562 563 self.show_recurrence_state(index, period) 564 else: 565 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), show_tzid=False, read_only=read_only) 566 if not read_only: 567 page.div(class_="dt disabled") 568 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") 569 page.div.close() 570 page.div(class_="dt enabled") 571 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") 572 page.div.close() 573 574 page.td.close() 575 576 # Show label as attendee. 577 578 else: 579 self.show_recurrence_label(index, period, recurrenceid, show_start) 580 581 def show_recurrence_label(self, index, period, recurrenceid, show_start): 582 583 """ 584 Show datetime details from the current object for the recurrence having 585 the given 'index', for the given recurrence 'period', employing any 586 'recurrenceid' for the object to configure the displayed information. 587 588 If 'show_start' is set to a true value, the start details will be shown; 589 otherwise, the end details will be shown. 590 """ 591 592 page = self.page 593 _name = self.element_name 594 595 try: 596 p = event_period_from_period(period) 597 except PeriodError, exc: 598 affected = False 599 else: 600 affected = p.is_affected(recurrenceid) 601 602 period = form_period_from_period(period) 603 604 css = " ".join([ 605 period.replaced and "replaced" or "", 606 affected and "affected" or "" 607 ]) 608 609 formdate = show_start and period.get_form_start() or period.get_form_end() 610 dt = formdate.as_datetime() 611 if dt: 612 page.td(class_=css) 613 if show_start: 614 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), read_only=True) 615 self.show_recurrence_state(index, period) 616 else: 617 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), show_tzid=False, read_only=True) 618 page.td.close() 619 else: 620 page.td("(Unrecognised date)") 621 622 def show_recurrence_state(self, index, period): 623 624 "Insert at 'index' additional state held by 'period'." 625 626 self.control("recur-origin", "hidden", period.origin or "") 627 self.control("recur-replaced", "hidden", period.replaced and str(index) or "") 628 self.control("recur-id", "hidden", period.recurrenceid or "") 629 630 # vim: tabstop=4 expandtab shiftwidth=4