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