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") 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, 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', and employing the given CSS 270 'class_' if specified. 271 """ 272 273 page = self.page 274 values = 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