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