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_="", 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 358 # For invalid datetimes, try to get a date instead. 359 360 if not dt: 361 dt = default.as_datetime(with_time=False) 362 363 # For invalid dates, just use today's date. 364 365 if not dt: 366 dt = date.today() 367 368 base = to_date(dt) 369 370 # Show a date label with a hidden field if read-only. 371 372 if read_only: 373 self.control("%s-date" % name, "hidden", format_datetime(base)) 374 page.span(self.format_date(base, "long")) 375 376 # Show dates for up to one week around the current date. 377 # NOTE: Support paging to other dates. 378 379 else: 380 items = [] 381 for i in range(-7, 8): 382 d = base + timedelta(i) 383 items.append((format_datetime(d), self.format_date(d, "full"))) 384 self.menu("%s-date" % name, format_datetime(base), items, index=index) 385 386 page.span.close() 387 388 # Show time details. 389 390 page.span(class_="time enabled") 391 392 if read_only: 393 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 394 self.control("%s-hour" % name, "hidden", default.get_hour()) 395 self.control("%s-minute" % name, "hidden", default.get_minute()) 396 self.control("%s-second" % name, "hidden", default.get_second()) 397 else: 398 self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 399 page.add(":") 400 self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 401 page.add(":") 402 self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 403 404 # Show time zone details. 405 406 if show_tzid: 407 page.add(" ") 408 tzid = default.get_tzid() or self.get_tzid() 409 410 # Show a label if read-only or a menu otherwise. 411 412 if read_only: 413 self.control("%s-tzid" % name, "hidden", tzid) 414 page.span(tzid) 415 else: 416 self.timezone_menu("%s-tzid" % name, tzid, index) 417 418 page.span.close() 419 420 def timezone_menu(self, name, default, index=None): 421 422 """ 423 Show timezone controls using a menu with the given 'name', set to the 424 given 'default' unless a field of the given 'name' provides a value. 425 """ 426 427 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 428 self.menu(name, default, entries, index=index) 429 430 class DateTimeFormUtilities: 431 432 "Date/time control methods resource mix-in." 433 434 # Control naming helpers. 435 436 def element_identifier(self, name, index=None): 437 return index is not None and "%s-%d" % (name, index) or name 438 439 def element_name(self, name, suffix, index=None): 440 return index is not None and "%s-%s" % (name, suffix) or name 441 442 def element_enable(self, index=None): 443 return str(index or 0) 444 445 def show_object_datetime_controls(self, period, index=None): 446 447 """ 448 Show datetime-related controls if already active or if an object needs 449 them for the given 'period'. The given 'index' is used to parameterise 450 individual controls for dynamic manipulation. 451 """ 452 453 p = form_period_from_period(period) 454 455 page = self.page 456 _id = self.element_identifier 457 _name = self.element_name 458 _enable = self.element_enable 459 460 # Add a dynamic stylesheet to permit the controls to modify the display. 461 # NOTE: The style details need to be coordinated with the static 462 # NOTE: stylesheet. 463 464 if index is not None: 465 page.style(type="text/css") 466 467 # Unlike the rules for object properties, these affect recurrence 468 # properties. 469 470 page.add("""\ 471 input#dttimes-enable-%(index)d, 472 input#dtend-enable-%(index)d, 473 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 474 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 475 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 476 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 477 display: none; 478 } 479 480 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .date.enabled, 481 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .date.disabled { 482 visibility: hidden; 483 }""" % {"index" : index}) 484 485 page.style.close() 486 487 self.control( 488 _name("dtend-control", "recur", index), "checkbox", 489 _enable(index), p.end_enabled, 490 id=_id("dtend-enable", index) 491 ) 492 493 self.control( 494 _name("dttimes-control", "recur", index), "checkbox", 495 _enable(index), p.times_enabled, 496 id=_id("dttimes-enable", index) 497 ) 498 499 def show_datetime_controls(self, formdate, show_start): 500 501 """ 502 Show datetime details from the current object for the 'formdate', 503 showing start details if 'show_start' is set to a true value. Details 504 will appear as controls for organisers and labels for attendees. 505 """ 506 507 page = self.page 508 509 # Show controls for editing. 510 511 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 512 513 if show_start: 514 page.div(class_="dt enabled") 515 self.date_controls("dtstart", formdate) 516 page.br() 517 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 518 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 519 page.div.close() 520 521 else: 522 self.date_controls("dtend", formdate) 523 page.div(class_="dt disabled") 524 page.label("Specify end date", for_="dtend-enable", class_="enable") 525 page.div.close() 526 page.div(class_="dt enabled") 527 page.label("End on same day", for_="dtend-enable", class_="disable") 528 page.div.close() 529 530 page.td.close() 531 532 def show_recurrence_controls(self, index, period, recurrenceid, show_start): 533 534 """ 535 Show datetime details from the current object for the recurrence having 536 the given 'index', with the recurrence period described by 'period', 537 indicating a start, end and origin of the period from the event details, 538 employing any 'recurrenceid' for the object to configure the displayed 539 information. 540 541 If 'show_start' is set to a true value, the start details will be shown; 542 otherwise, the end details will be shown. 543 """ 544 545 page = self.page 546 _id = self.element_identifier 547 _name = self.element_name 548 549 period = form_period_from_period(period) 550 551 # Show controls for editing. 552 553 if not period.replaced: 554 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 555 556 read_only = period.origin == "RRULE" 557 558 if show_start: 559 page.div(class_="dt enabled") 560 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=read_only) 561 if not read_only: 562 page.br() 563 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 564 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 565 page.div.close() 566 567 # Put the origin somewhere. 568 569 self.control("recur-origin", "hidden", period.origin or "") 570 self.control("recur-replaced", "hidden", period.replaced and str(index) or "") 571 572 else: 573 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=read_only) 574 if not read_only: 575 page.div(class_="dt disabled") 576 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") 577 page.div.close() 578 page.div(class_="dt enabled") 579 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") 580 page.div.close() 581 582 page.td.close() 583 584 # Show label as attendee. 585 586 else: 587 self.show_recurrence_label(index, period, recurrenceid, show_start) 588 589 def show_recurrence_label(self, index, period, recurrenceid, show_start): 590 591 """ 592 Show datetime details from the current object for the recurrence having 593 the given 'index', for the given recurrence 'period', employing any 594 'recurrenceid' for the object to configure the displayed information. 595 596 If 'show_start' is set to a true value, the start details will be shown; 597 otherwise, the end details will be shown. 598 """ 599 600 page = self.page 601 _name = self.element_name 602 603 try: 604 p = event_period_from_period(period) 605 except PeriodError, exc: 606 affected = False 607 else: 608 affected = p.is_affected(recurrenceid) 609 610 period = form_period_from_period(period) 611 612 css = " ".join([ 613 period.replaced and "replaced" or "", 614 affected and "affected" or "" 615 ]) 616 617 formdate = show_start and period.get_form_start() or period.get_form_end() 618 dt = formdate.as_datetime() 619 if dt: 620 page.td(class_=css) 621 if show_start: 622 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=True) 623 self.control("recur-origin", "hidden", period.origin or "") 624 self.control("recur-replaced", "hidden", period.replaced and str(index) or "") 625 else: 626 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=True) 627 page.td.close() 628 else: 629 page.td("(Unrecognised date)") 630 631 # vim: tabstop=4 expandtab shiftwidth=4