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 if not period.replaced: 546 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 547 548 read_only = period.origin == "RRULE" 549 550 if show_start: 551 page.div(class_="dt enabled") 552 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), read_only=read_only) 553 if not read_only: 554 page.br() 555 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 556 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 557 page.div.close() 558 559 self.show_recurrence_state(index, period) 560 else: 561 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), 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(), read_only=True) 611 self.show_recurrence_state(index, period) 612 else: 613 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), show_tzid=False, read_only=True) 614 page.td.close() 615 else: 616 page.td("(Unrecognised date)") 617 618 def show_recurrence_state(self, index, period): 619 620 "Insert at 'index' additional state held by 'period'." 621 622 self.control("recur-origin", "hidden", period.origin or "") 623 self.control("recur-replaced", "hidden", period.replaced and str(index) or "") 624 self.control("recur-id", "hidden", period.recurrenceid or "") 625 626 # vim: tabstop=4 expandtab shiftwidth=4