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