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