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, uri_values 25 from imiptools.dates import format_datetime, get_recurrence_start_point, 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, FormDate 28 from imipweb.env import CGIEnvironment 29 import babel.dates 30 import imip_store 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, recurrenceid=None): 85 86 """ 87 Return a link to an object with the given 'uid' and 'recurrenceid'. 88 See get_identifiers for the decoding of such links. 89 """ 90 91 path = [uid] 92 if recurrenceid: 93 path.append(recurrenceid) 94 return self.env.new_url("/".join(path)) 95 96 # Control naming helpers. 97 98 def element_identifier(self, name, index=None): 99 return index is not None and "%s-%d" % (name, index) or name 100 101 def element_name(self, name, suffix, index=None): 102 return index is not None and "%s-%s" % (name, suffix) or name 103 104 def element_enable(self, index=None): 105 return index is not None and str(index) or "enable" 106 107 # Access to objects. 108 109 def get_identifiers(self, path_info): 110 111 """ 112 Return identifiers provided by 'path_info', potentially encoded by 113 'link_to'. 114 """ 115 116 parts = path_info.lstrip("/").split("/") 117 118 # UID only. 119 120 if len(parts) == 1: 121 return parts[0], None 122 123 # UID and RECURRENCE-ID. 124 125 else: 126 return parts[:2] 127 128 def _get_object(self, uid, recurrenceid=None, section=None): 129 if self.objects.has_key((uid, recurrenceid, section)): 130 return self.objects[(uid, recurrenceid, section)] 131 132 obj = self.objects[(uid, recurrenceid, section)] = self.get_stored_object(uid, recurrenceid, section) 133 return obj 134 135 def _get_recurrences(self, uid): 136 return self.store.get_recurrences(self.user, uid) 137 138 def _get_active_recurrences(self, uid): 139 return self.store.get_active_recurrences(self.user, uid) 140 141 def _get_requests(self): 142 if self.requests is None: 143 self.requests = self.store.get_requests(self.user) 144 return self.requests 145 146 def _have_request(self, uid, recurrenceid=None, type=None, strict=False): 147 return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict) 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 obj = self.get_stored_object(uid, recurrenceid) 160 if obj: 161 recurrenceids = self._get_active_recurrences(uid) 162 163 # Obtain only active periods, not those replaced by redefined 164 # recurrences, converting to free/busy periods. 165 166 for p in obj.get_active_periods(recurrenceids, self.get_tzid(), self.get_window_end()): 167 summary.append(obj.get_freebusy_period(p)) 168 169 return summary 170 171 # Preference methods. 172 173 def get_user_locale(self): 174 if not self.locale: 175 self.locale = self.get_preferences().get("LANG", "en") 176 return self.locale 177 178 # Prettyprinting of dates and times. 179 180 def format_date(self, dt, format): 181 return self._format_datetime(babel.dates.format_date, dt, format) 182 183 def format_time(self, dt, format): 184 return self._format_datetime(babel.dates.format_time, dt, format) 185 186 def format_datetime(self, dt, format): 187 return self._format_datetime( 188 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 189 dt, format) 190 191 def _format_datetime(self, fn, dt, format): 192 return fn(dt, format=format, locale=self.get_user_locale()) 193 194 # Data management methods. 195 196 def remove_request(self, uid, recurrenceid=None): 197 return self.store.dequeue_request(self.user, uid, recurrenceid) 198 199 def remove_event(self, uid, recurrenceid=None): 200 return self.store.remove_event(self.user, uid, recurrenceid) 201 202 class ResourceClient(Resource, Client): 203 204 "A Web application resource and calendar client." 205 206 def __init__(self, resource=None): 207 Resource.__init__(self, resource) 208 user = self.env.get_user() 209 Client.__init__(self, user and get_uri(user) or None) 210 211 class ResourceClientForObject(Resource, ClientForObject): 212 213 "A Web application resource and calendar client for a specific object." 214 215 def __init__(self, resource=None): 216 Resource.__init__(self, resource) 217 user = self.env.get_user() 218 ClientForObject.__init__(self, None, user and get_uri(user) or None) 219 220 class FormUtilities: 221 222 "Utility methods resource mix-in." 223 224 def control(self, name, type, value, selected=False, **kw): 225 226 """ 227 Show a control with the given 'name', 'type' and 'value', with 228 'selected' indicating whether it should be selected (checked or 229 equivalent), and with keyword arguments setting other properties. 230 """ 231 232 page = self.page 233 if selected: 234 page.input(name=name, type=type, value=value, checked=selected, **kw) 235 else: 236 page.input(name=name, type=type, value=value, **kw) 237 238 def menu(self, name, default, items, class_="", index=None): 239 240 """ 241 Show a select menu having the given 'name', set to the given 'default', 242 providing the given (value, label) 'items', and employing the given CSS 243 'class_' if specified. 244 """ 245 246 page = self.page 247 values = self.env.get_args().get(name, [default]) 248 if index is not None: 249 values = values[index:] 250 values = values and values[0:1] or [default] 251 252 page.select(name=name, class_=class_) 253 for v, label in items: 254 if v is None: 255 continue 256 if v in values: 257 page.option(label, value=v, selected="selected") 258 else: 259 page.option(label, value=v) 260 page.select.close() 261 262 def date_controls(self, name, default, index=None, show_tzid=True, read_only=False): 263 264 """ 265 Show date controls for a field with the given 'name' and 'default' form 266 date value. 267 268 If 'index' is specified, default field values will be overridden by the 269 element from a collection of existing form values with the specified 270 index; otherwise, field values will be overridden by a single form 271 value. 272 273 If 'show_tzid' is set to a false value, the time zone menu will not be 274 provided. 275 276 If 'read_only' is set to a true value, the controls will be hidden and 277 labels will be employed instead. 278 """ 279 280 page = self.page 281 282 # Show dates for up to one week around the current date. 283 284 dt = default.as_datetime() 285 if not dt: 286 dt = date.today() 287 288 base = to_date(dt) 289 290 # Show a date label with a hidden field if read-only. 291 292 if read_only: 293 self.control("%s-date" % name, "hidden", format_datetime(base)) 294 page.span(self.format_date(base, "long")) 295 296 # Show dates for up to one week around the current date. 297 # NOTE: Support paging to other dates. 298 299 else: 300 items = [] 301 for i in range(-7, 8): 302 d = base + timedelta(i) 303 items.append((format_datetime(d), self.format_date(d, "full"))) 304 self.menu("%s-date" % name, format_datetime(base), items, index=index) 305 306 # Show time details. 307 308 page.span(class_="time enabled") 309 310 if read_only: 311 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 312 self.control("%s-hour" % name, "hidden", default.get_hour()) 313 self.control("%s-minute" % name, "hidden", default.get_minute()) 314 self.control("%s-second" % name, "hidden", default.get_second()) 315 else: 316 self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 317 page.add(":") 318 self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 319 page.add(":") 320 self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 321 322 # Show time zone details. 323 324 if show_tzid: 325 page.add(" ") 326 tzid = default.get_tzid() or self.get_tzid() 327 328 # Show a label if read-only or a menu otherwise. 329 330 if read_only: 331 self.control("%s-tzid" % name, "hidden", tzid) 332 page.span(tzid) 333 else: 334 self.timezone_menu("%s-tzid" % name, tzid, index) 335 336 page.span.close() 337 338 def timezone_menu(self, name, default, index=None): 339 340 """ 341 Show timezone controls using a menu with the given 'name', set to the 342 given 'default' unless a field of the given 'name' provides a value. 343 """ 344 345 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 346 self.menu(name, default, entries, index=index) 347 348 class DateTimeFormUtilities: 349 350 "Date/time control methods resource mix-in." 351 352 def show_object_datetime_controls(self, period, index=None): 353 354 """ 355 Show datetime-related controls if already active or if an object needs 356 them for the given 'period'. The given 'index' is used to parameterise 357 individual controls for dynamic manipulation. 358 """ 359 360 p = form_period_from_period(period) 361 362 page = self.page 363 args = self.env.get_args() 364 _id = self.element_identifier 365 _name = self.element_name 366 _enable = self.element_enable 367 368 # Add a dynamic stylesheet to permit the controls to modify the display. 369 # NOTE: The style details need to be coordinated with the static 370 # NOTE: stylesheet. 371 372 if index is not None: 373 page.style(type="text/css") 374 375 # Unlike the rules for object properties, these affect recurrence 376 # properties. 377 378 page.add("""\ 379 input#dttimes-enable-%(index)d, 380 input#dtend-enable-%(index)d, 381 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 382 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 383 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 384 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 385 display: none; 386 }""" % {"index" : index}) 387 388 page.style.close() 389 390 self.control( 391 _name("dtend-control", "recur", index), "checkbox", 392 _enable(index), p.end_enabled, 393 id=_id("dtend-enable", index) 394 ) 395 396 self.control( 397 _name("dttimes-control", "recur", index), "checkbox", 398 _enable(index), p.times_enabled, 399 id=_id("dttimes-enable", index) 400 ) 401 402 def show_datetime_controls(self, formdate, show_start): 403 404 """ 405 Show datetime details from the current object for the 'formdate', 406 showing start details if 'show_start' is set to a true value. Details 407 will appear as controls for organisers and labels for attendees. 408 """ 409 410 page = self.page 411 412 # Show controls for editing as organiser. 413 414 if self.is_organiser(): 415 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 416 417 if show_start: 418 page.div(class_="dt enabled") 419 self.date_controls("dtstart", formdate) 420 page.br() 421 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 422 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 423 page.div.close() 424 425 else: 426 page.div(class_="dt disabled") 427 page.label("Specify end date", for_="dtend-enable", class_="enable") 428 page.div.close() 429 page.div(class_="dt enabled") 430 self.date_controls("dtend", formdate) 431 page.br() 432 page.label("End on same day", for_="dtend-enable", class_="disable") 433 page.div.close() 434 435 page.td.close() 436 437 # Show a label as attendee. 438 439 else: 440 dt = formdate.as_datetime() 441 if dt: 442 page.td(self.format_datetime(dt, "full")) 443 else: 444 page.td("(Unrecognised date)") 445 446 def show_recurrence_controls(self, index, period, recurrenceid, recurrenceids, show_start): 447 448 """ 449 Show datetime details from the current object for the recurrence having 450 the given 'index', with the recurrence period described by 'period', 451 indicating a start, end and origin of the period from the event details, 452 employing any 'recurrenceid' and 'recurrenceids' for the object to 453 configure the displayed information. 454 455 If 'show_start' is set to a true value, the start details will be shown; 456 otherwise, the end details will be shown. 457 """ 458 459 page = self.page 460 _id = self.element_identifier 461 _name = self.element_name 462 463 p = event_period_from_period(period) 464 replaced = not recurrenceid and p.is_replaced(recurrenceids) 465 466 # Show controls for editing as organiser. 467 468 if self.is_organiser() and not replaced: 469 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 470 471 read_only = period.origin == "RRULE" 472 473 if show_start: 474 page.div(class_="dt enabled") 475 self.date_controls(_name("dtstart", "recur", index), p.get_form_start(), index=index, read_only=read_only) 476 if not read_only: 477 page.br() 478 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 479 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 480 page.div.close() 481 482 # Put the origin somewhere. 483 484 self.control("recur-origin", "hidden", p.origin or "") 485 486 else: 487 page.div(class_="dt disabled") 488 if not read_only: 489 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") 490 page.div.close() 491 page.div(class_="dt enabled") 492 self.date_controls(_name("dtend", "recur", index), p.get_form_end(), index=index, show_tzid=False, read_only=read_only) 493 if not read_only: 494 page.br() 495 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") 496 page.div.close() 497 498 page.td.close() 499 500 # Show label as attendee. 501 502 else: 503 self.show_recurrence_label(p, recurrenceid, recurrenceids, show_start) 504 505 def show_recurrence_label(self, period, recurrenceid, recurrenceids, show_start): 506 507 """ 508 Show datetime details for the given 'period', employing any 509 'recurrenceid' and 'recurrenceids' for the object to configure the 510 displayed information. 511 512 If 'show_start' is set to a true value, the start details will be shown; 513 otherwise, the end details will be shown. 514 """ 515 516 page = self.page 517 518 p = event_period_from_period(period) 519 replaced = not recurrenceid and p.is_replaced(recurrenceids) 520 521 css = " ".join([ 522 replaced and "replaced" or "", 523 p.is_affected(recurrenceid) and "affected" or "" 524 ]) 525 526 formdate = show_start and p.get_form_start() or p.get_form_end() 527 dt = formdate.as_datetime() 528 if dt: 529 page.td(self.format_datetime(dt, "long"), class_=css) 530 else: 531 page.td("(Unrecognised date)") 532 533 def get_date_control_values(self, name, multiple=False, tzid_name=None): 534 535 """ 536 Return a dictionary containing date, time and tzid entries for fields 537 starting with 'name'. If 'multiple' is set to a true value, many 538 dictionaries will be returned corresponding to a collection of 539 datetimes. If 'tzid_name' is specified, the time zone information will 540 be acquired from a field starting with 'tzid_name' instead of 'name'. 541 """ 542 543 args = self.env.get_args() 544 545 dates = args.get("%s-date" % name, []) 546 hours = args.get("%s-hour" % name, []) 547 minutes = args.get("%s-minute" % name, []) 548 seconds = args.get("%s-second" % name, []) 549 tzids = args.get("%s-tzid" % (tzid_name or name), []) 550 551 # Handle absent values by employing None values. 552 553 field_values = map(None, dates, hours, minutes, seconds, tzids) 554 555 if not field_values and not multiple: 556 all_values = FormDate() 557 else: 558 all_values = [] 559 for date, hour, minute, second, tzid in field_values: 560 value = FormDate(date, hour, minute, second, tzid or self.get_tzid()) 561 562 # Return a single value or append to a collection of all values. 563 564 if not multiple: 565 return value 566 else: 567 all_values.append(value) 568 569 return all_values 570 571 # vim: tabstop=4 expandtab shiftwidth=4