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