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 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, 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_="", index=None): 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 self.env.get_args().get(name, [default]) 318 if index is not None: 319 values = values[index:] 320 values = values and values[0:1] or [default] 321 322 page.select(name=name, class_=class_) 323 for v, label in items: 324 if v is None: 325 continue 326 if v in values: 327 page.option(label, value=v, selected="selected") 328 else: 329 page.option(label, value=v) 330 page.select.close() 331 332 def date_controls(self, name, default, index=None, show_tzid=True, read_only=False): 333 334 """ 335 Show date controls for a field with the given 'name' and 'default' form 336 date value. 337 338 If 'index' is specified, default field values will be overridden by the 339 element from a collection of existing form values with the specified 340 index; otherwise, field values will be overridden by a single form 341 value. 342 343 If 'show_tzid' is set to a false value, the time zone menu will not be 344 provided. 345 346 If 'read_only' is set to a true value, the controls will be hidden and 347 labels will be employed instead. 348 """ 349 350 page = self.page 351 352 # Show dates for up to one week around the current date. 353 354 page.span(class_="date enabled") 355 356 dt = default.as_datetime() 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, index=index) 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, index) 409 410 page.span.close() 411 412 def timezone_menu(self, name, default, index=None): 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, index=index) 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 index is not None and str(index) or "enable" 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 args = self.env.get_args() 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 args = self.env.get_args() 636 637 dates = args.get("%s-date" % name, []) 638 hours = args.get("%s-hour" % name, []) 639 minutes = args.get("%s-minute" % name, []) 640 seconds = args.get("%s-second" % name, []) 641 tzids = args.get("%s-tzid" % (tzid_name or name), []) 642 643 # Handle absent values by employing None values. 644 645 field_values = map(None, dates, hours, minutes, seconds, tzids) 646 647 if not field_values and not multiple: 648 all_values = FormDate() 649 else: 650 all_values = [] 651 for date, hour, minute, second, tzid in field_values: 652 value = FormDate(date, hour, minute, second, tzid or self.get_tzid()) 653 654 # Return a single value or append to a collection of all values. 655 656 if not multiple: 657 return value 658 else: 659 all_values.append(value) 660 661 return all_values 662 663 def set_date_control_values(self, name, formdates, tzid_name=None): 664 665 """ 666 Replace form fields starting with 'name' using the values of the given 667 'formdates'. 668 669 If 'tzid_name' is specified, the time zone information will be stored in 670 fields starting with 'tzid_name' instead of 'name'. 671 """ 672 673 args = self.env.get_args() 674 675 args["%s-date" % name] = [d.date for d in formdates] 676 args["%s-hour" % name] = [d.hour for d in formdates] 677 args["%s-minute" % name] = [d.minute for d in formdates] 678 args["%s-second" % name] = [d.second for d in formdates] 679 args["%s-tzid" % (tzid_name or name)] = [d.tzid for d in formdates] 680 681 # vim: tabstop=4 expandtab shiftwidth=4