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