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