1 #!/usr/bin/env python 2 3 """ 4 Common resource functionality for Web calendar clients. 5 6 Copyright (C) 2014, 2015, 2016 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 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.period import FreeBusyCollection 27 from imipweb.data import event_period_from_period, form_period_from_period, \ 28 FormDate, 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): 154 155 "Return a list of periods comprising the request summary." 156 157 summary = FreeBusyCollection() 158 159 for uid, recurrenceid, request_type in self._get_requests(): 160 161 # Obtain either normal objects or counter-proposals. 162 163 if not request_type: 164 objs = [self._get_object(uid, recurrenceid)] 165 elif request_type == "COUNTER": 166 objs = [] 167 for attendee in self.store.get_counters(self.user, uid, recurrenceid): 168 objs.append(self._get_object(uid, recurrenceid, "counters", attendee)) 169 170 # For each object, obtain the periods involved. 171 172 for obj in objs: 173 if obj: 174 recurrenceids = self._get_recurrences(uid) 175 176 # Obtain only active periods, not those replaced by redefined 177 # recurrences, converting to free/busy periods. 178 179 for p in obj.get_active_periods(recurrenceids, self.get_tzid(), self.get_window_end()): 180 summary.append(obj.get_freebusy_period(p)) 181 182 return summary 183 184 # Preference methods. 185 186 def get_user_locale(self): 187 if not self.locale: 188 self.locale = self.get_preferences().get("LANG", "en", True) or "en" 189 return self.locale 190 191 # Prettyprinting of dates and times. 192 193 def format_date(self, dt, format): 194 return self._format_datetime(babel.dates.format_date, dt, format) 195 196 def format_time(self, dt, format): 197 return self._format_datetime(babel.dates.format_time, dt, format) 198 199 def format_datetime(self, dt, format): 200 return self._format_datetime( 201 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 202 dt, format) 203 204 def _format_datetime(self, fn, dt, format): 205 return fn(dt, format=format, locale=self.get_user_locale()) 206 207 class ResourceClient(Resource, Client): 208 209 "A Web application resource and calendar client." 210 211 def __init__(self, resource=None): 212 Resource.__init__(self, resource) 213 user = self.env.get_user() 214 Client.__init__(self, user and get_uri(user) or None) 215 216 class ResourceClientForObject(Resource, ClientForObject): 217 218 "A Web application resource and calendar client for a specific object." 219 220 def __init__(self, resource=None, messenger=None): 221 Resource.__init__(self, resource) 222 user = self.env.get_user() 223 ClientForObject.__init__(self, None, user and get_uri(user) or None, messenger) 224 225 class FormUtilities: 226 227 "Utility methods resource mix-in." 228 229 def get_validation_token(self, details=None): 230 231 "Return a token suitable for validating a form submission." 232 233 # Use a secret held in the user's preferences. 234 235 prefs = self.get_preferences() 236 if not prefs.has_key("secret"): 237 prefs["secret"] = str(time.time()) 238 239 # Combine it with the user identity and any supplied details. 240 241 secret = prefs["secret"].encode("utf-8") 242 details = u"".join([self.env.get_user()] + (details or [])).encode("utf-8") 243 244 return hmac.new(secret, details, hashlib.sha256).hexdigest() 245 246 def check_validation_token(self, name="token", details=None): 247 248 """ 249 Check the field having the given 'name', returning if its value matches 250 the validation token generated using any given 'details'. 251 """ 252 253 return self.env.get_args().get(name, [None])[0] == self.get_validation_token(details) 254 255 def validator(self, name="token", details=None): 256 257 """ 258 Show a control having the given 'name' that is used to validate form 259 submissions, employing any additional 'details' in the construction of 260 the validation token. 261 """ 262 263 self.page.input(name=name, type="hidden", value=self.get_validation_token(details)) 264 265 def prefixed_args(self, prefix, convert=None): 266 267 """ 268 Return values for all arguments having the given 'prefix' in their 269 names, removing the prefix to obtain each value from the argument name 270 itself. The 'convert' callable can be specified to perform a conversion 271 (to int, for example). 272 """ 273 274 args = self.env.get_args() 275 276 values = [] 277 for name in args.keys(): 278 if name.startswith(prefix): 279 value = name[len(prefix):] 280 if convert: 281 try: 282 value = convert(value) 283 except ValueError: 284 pass 285 values.append(value) 286 return values 287 288 def control(self, name, type, value, selected=False, **kw): 289 290 """ 291 Show a control with the given 'name', 'type' and 'value', with 292 'selected' indicating whether it should be selected (checked or 293 equivalent), and with keyword arguments setting other properties. 294 """ 295 296 page = self.page 297 if type in ("checkbox", "radio") and selected: 298 page.input(name=name, type=type, value=value, checked="checked", **kw) 299 else: 300 page.input(name=name, type=type, value=value, **kw) 301 302 def menu(self, name, default, items, values=None, class_="", index=None): 303 304 """ 305 Show a select menu having the given 'name', set to the given 'default', 306 providing the given (value, label) 'items', selecting the given 'values' 307 (or using the request parameters if not specified), and employing the 308 given CSS 'class_' if specified. 309 """ 310 311 page = self.page 312 values = values or self.env.get_args().get(name, [default]) 313 if index is not None: 314 values = values[index:] 315 values = values and values[0:1] or [default] 316 317 page.select(name=name, class_=class_) 318 for v, label in items: 319 if v is None: 320 continue 321 if v in values: 322 page.option(label, value=v, selected="selected") 323 else: 324 page.option(label, value=v) 325 page.select.close() 326 327 def date_controls(self, name, default, index=None, show_tzid=True, read_only=False): 328 329 """ 330 Show date controls for a field with the given 'name' and 'default' form 331 date value. 332 333 If 'index' is specified, default field values will be overridden by the 334 element from a collection of existing form values with the specified 335 index; otherwise, field values will be overridden by a single form 336 value. 337 338 If 'show_tzid' is set to a false value, the time zone menu will not be 339 provided. 340 341 If 'read_only' is set to a true value, the controls will be hidden and 342 labels will be employed instead. 343 """ 344 345 page = self.page 346 347 # Show dates for up to one week around the current date. 348 349 page.span(class_="date enabled") 350 351 dt = default.as_datetime() 352 if not dt: 353 dt = date.today() 354 355 base = to_date(dt) 356 357 # Show a date label with a hidden field if read-only. 358 359 if read_only: 360 self.control("%s-date" % name, "hidden", format_datetime(base)) 361 page.span(self.format_date(base, "long")) 362 363 # Show dates for up to one week around the current date. 364 # NOTE: Support paging to other dates. 365 366 else: 367 items = [] 368 for i in range(-7, 8): 369 d = base + timedelta(i) 370 items.append((format_datetime(d), self.format_date(d, "full"))) 371 self.menu("%s-date" % name, format_datetime(base), items, index=index) 372 373 page.span.close() 374 375 # Show time details. 376 377 page.span(class_="time enabled") 378 379 if read_only: 380 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 381 self.control("%s-hour" % name, "hidden", default.get_hour()) 382 self.control("%s-minute" % name, "hidden", default.get_minute()) 383 self.control("%s-second" % name, "hidden", default.get_second()) 384 else: 385 self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 386 page.add(":") 387 self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 388 page.add(":") 389 self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 390 391 # Show time zone details. 392 393 if show_tzid: 394 page.add(" ") 395 tzid = default.get_tzid() or self.get_tzid() 396 397 # Show a label if read-only or a menu otherwise. 398 399 if read_only: 400 self.control("%s-tzid" % name, "hidden", tzid) 401 page.span(tzid) 402 else: 403 self.timezone_menu("%s-tzid" % name, tzid, index) 404 405 page.span.close() 406 407 def timezone_menu(self, name, default, index=None): 408 409 """ 410 Show timezone controls using a menu with the given 'name', set to the 411 given 'default' unless a field of the given 'name' provides a value. 412 """ 413 414 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 415 self.menu(name, default, entries, index=index) 416 417 class DateTimeFormUtilities: 418 419 "Date/time control methods resource mix-in." 420 421 # Control naming helpers. 422 423 def element_identifier(self, name, index=None): 424 return index is not None and "%s-%d" % (name, index) or name 425 426 def element_name(self, name, suffix, index=None): 427 return index is not None and "%s-%s" % (name, suffix) or name 428 429 def element_enable(self, index=None): 430 return index is not None and str(index) or "enable" 431 432 def show_object_datetime_controls(self, period, index=None): 433 434 """ 435 Show datetime-related controls if already active or if an object needs 436 them for the given 'period'. The given 'index' is used to parameterise 437 individual controls for dynamic manipulation. 438 """ 439 440 p = form_period_from_period(period) 441 442 page = self.page 443 args = self.env.get_args() 444 _id = self.element_identifier 445 _name = self.element_name 446 _enable = self.element_enable 447 448 # Add a dynamic stylesheet to permit the controls to modify the display. 449 # NOTE: The style details need to be coordinated with the static 450 # NOTE: stylesheet. 451 452 if index is not None: 453 page.style(type="text/css") 454 455 # Unlike the rules for object properties, these affect recurrence 456 # properties. 457 458 page.add("""\ 459 input#dttimes-enable-%(index)d, 460 input#dtend-enable-%(index)d, 461 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 462 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 463 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 464 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 465 display: none; 466 } 467 468 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .date.enabled, 469 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .date.disabled { 470 visibility: hidden; 471 }""" % {"index" : index}) 472 473 page.style.close() 474 475 self.control( 476 _name("dtend-control", "recur", index), "checkbox", 477 _enable(index), p.end_enabled, 478 id=_id("dtend-enable", index) 479 ) 480 481 self.control( 482 _name("dttimes-control", "recur", index), "checkbox", 483 _enable(index), p.times_enabled, 484 id=_id("dttimes-enable", index) 485 ) 486 487 def show_datetime_controls(self, formdate, show_start): 488 489 """ 490 Show datetime details from the current object for the 'formdate', 491 showing start details if 'show_start' is set to a true value. Details 492 will appear as controls for organisers and labels for attendees. 493 """ 494 495 page = self.page 496 497 # Show controls for editing. 498 499 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 500 501 if show_start: 502 page.div(class_="dt enabled") 503 self.date_controls("dtstart", formdate) 504 page.br() 505 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 506 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 507 page.div.close() 508 509 else: 510 self.date_controls("dtend", formdate) 511 page.div(class_="dt disabled") 512 page.label("Specify end date", for_="dtend-enable", class_="enable") 513 page.div.close() 514 page.div(class_="dt enabled") 515 page.label("End on same day", for_="dtend-enable", class_="disable") 516 page.div.close() 517 518 page.td.close() 519 520 def show_recurrence_controls(self, index, period, recurrenceid, show_start): 521 522 """ 523 Show datetime details from the current object for the recurrence having 524 the given 'index', with the recurrence period described by 'period', 525 indicating a start, end and origin of the period from the event details, 526 employing any 'recurrenceid' for the object to configure the displayed 527 information. 528 529 If 'show_start' is set to a true value, the start details will be shown; 530 otherwise, the end details will be shown. 531 """ 532 533 page = self.page 534 _id = self.element_identifier 535 _name = self.element_name 536 537 period = form_period_from_period(period) 538 539 # Show controls for editing. 540 541 if not period.replaced: 542 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 543 544 read_only = period.origin == "RRULE" 545 546 if show_start: 547 page.div(class_="dt enabled") 548 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=read_only) 549 if not read_only: 550 page.br() 551 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 552 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 553 page.div.close() 554 555 # Put the origin somewhere. 556 557 self.control("recur-origin", "hidden", period.origin or "") 558 self.control("recur-replaced", "hidden", period.replaced and str(index) or "") 559 560 else: 561 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=read_only) 562 if not read_only: 563 page.div(class_="dt disabled") 564 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") 565 page.div.close() 566 page.div(class_="dt enabled") 567 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") 568 page.div.close() 569 570 page.td.close() 571 572 # Show label as attendee. 573 574 else: 575 self.show_recurrence_label(index, period, recurrenceid, show_start) 576 577 def show_recurrence_label(self, index, period, recurrenceid, show_start): 578 579 """ 580 Show datetime details from the current object for the recurrence having 581 the given 'index', for the given recurrence 'period', employing any 582 'recurrenceid' for the object to configure the displayed information. 583 584 If 'show_start' is set to a true value, the start details will be shown; 585 otherwise, the end details will be shown. 586 """ 587 588 page = self.page 589 _name = self.element_name 590 591 try: 592 p = event_period_from_period(period) 593 except PeriodError, exc: 594 affected = False 595 else: 596 affected = p.is_affected(recurrenceid) 597 598 period = form_period_from_period(period) 599 600 css = " ".join([ 601 period.replaced and "replaced" or "", 602 affected and "affected" or "" 603 ]) 604 605 formdate = show_start and period.get_form_start() or period.get_form_end() 606 dt = formdate.as_datetime() 607 if dt: 608 page.td(class_=css) 609 if show_start: 610 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=True) 611 self.control("recur-origin", "hidden", period.origin or "") 612 self.control("recur-replaced", "hidden", period.replaced and str(index) or "") 613 else: 614 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=True) 615 page.td.close() 616 else: 617 page.td("(Unrecognised date)") 618 619 def get_date_control_values(self, name, multiple=False, tzid_name=None): 620 621 """ 622 Return a form date object representing fields starting with 'name'. If 623 'multiple' is set to a true value, many date objects will be returned 624 corresponding to a collection of datetimes. 625 626 If 'tzid_name' is specified, the time zone information will be acquired 627 from fields starting with 'tzid_name' instead of 'name'. 628 """ 629 630 args = self.env.get_args() 631 632 dates = args.get("%s-date" % name, []) 633 hours = args.get("%s-hour" % name, []) 634 minutes = args.get("%s-minute" % name, []) 635 seconds = args.get("%s-second" % name, []) 636 tzids = args.get("%s-tzid" % (tzid_name or name), []) 637 638 # Handle absent values by employing None values. 639 640 field_values = map(None, dates, hours, minutes, seconds, tzids) 641 642 if not field_values and not multiple: 643 all_values = FormDate() 644 else: 645 all_values = [] 646 for date, hour, minute, second, tzid in field_values: 647 value = FormDate(date, hour, minute, second, tzid or self.get_tzid()) 648 649 # Return a single value or append to a collection of all values. 650 651 if not multiple: 652 return value 653 else: 654 all_values.append(value) 655 656 return all_values 657 658 def set_date_control_values(self, name, formdates, tzid_name=None): 659 660 """ 661 Replace form fields starting with 'name' using the values of the given 662 'formdates'. 663 664 If 'tzid_name' is specified, the time zone information will be stored in 665 fields starting with 'tzid_name' instead of 'name'. 666 """ 667 668 args = self.env.get_args() 669 670 args["%s-date" % name] = [d.date for d in formdates] 671 args["%s-hour" % name] = [d.hour for d in formdates] 672 args["%s-minute" % name] = [d.minute for d in formdates] 673 args["%s-second" % name] = [d.second for d in formdates] 674 args["%s-tzid" % (tzid_name or name)] = [d.tzid for d in formdates] 675 676 # vim: tabstop=4 expandtab shiftwidth=4