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