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 # Access to objects. 97 98 def get_identifiers(self, path_info): 99 100 """ 101 Return identifiers provided by 'path_info', potentially encoded by 102 'link_to'. 103 """ 104 105 parts = path_info.lstrip("/").split("/") 106 107 # UID only. 108 109 if len(parts) == 1: 110 return parts[0], None 111 112 # UID and RECURRENCE-ID. 113 114 else: 115 return parts[:2] 116 117 def _get_object(self, uid, recurrenceid=None, section=None, username=None): 118 if self.objects.has_key((uid, recurrenceid, section, username)): 119 return self.objects[(uid, recurrenceid, section, username)] 120 121 obj = self.objects[(uid, recurrenceid, section, username)] = self.get_stored_object(uid, recurrenceid, section, username) 122 return obj 123 124 def _get_recurrences(self, uid): 125 return self.store.get_recurrences(self.user, uid) 126 127 def _get_active_recurrences(self, uid): 128 return self.store.get_active_recurrences(self.user, uid) 129 130 def _get_requests(self): 131 if self.requests is None: 132 self.requests = self.store.get_requests(self.user) 133 return self.requests 134 135 def _have_request(self, uid, recurrenceid=None, type=None, strict=False): 136 return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict) 137 138 def _get_counters(self, uid, recurrenceid=None): 139 return self.store.get_counters(self.user, uid, recurrenceid) 140 141 def _get_request_summary(self): 142 143 "Return a list of periods comprising the request summary." 144 145 summary = [] 146 147 for uid, recurrenceid, request_type in self._get_requests(): 148 149 # Obtain either normal objects or counter-proposals. 150 151 if not request_type: 152 objs = [self._get_object(uid, recurrenceid)] 153 elif request_type == "COUNTER": 154 objs = [] 155 for attendee in self.store.get_counters(self.user, uid, recurrenceid): 156 objs.append(self._get_object(uid, recurrenceid, "counters", attendee)) 157 158 # For each object, obtain the periods involved. 159 160 for obj in objs: 161 if obj: 162 recurrenceids = self._get_active_recurrences(uid) 163 164 # Obtain only active periods, not those replaced by redefined 165 # recurrences, converting to free/busy periods. 166 167 for p in obj.get_active_periods(recurrenceids, self.get_tzid(), self.get_window_end()): 168 summary.append(obj.get_freebusy_period(p)) 169 170 return summary 171 172 # Preference methods. 173 174 def get_user_locale(self): 175 if not self.locale: 176 self.locale = self.get_preferences().get("LANG", "en") 177 return self.locale 178 179 # Prettyprinting of dates and times. 180 181 def format_date(self, dt, format): 182 return self._format_datetime(babel.dates.format_date, dt, format) 183 184 def format_time(self, dt, format): 185 return self._format_datetime(babel.dates.format_time, dt, format) 186 187 def format_datetime(self, dt, format): 188 return self._format_datetime( 189 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 190 dt, format) 191 192 def _format_datetime(self, fn, dt, format): 193 return fn(dt, format=format, locale=self.get_user_locale()) 194 195 # Data management methods. 196 197 def remove_request(self, uid, recurrenceid=None): 198 return self.store.dequeue_request(self.user, uid, recurrenceid) 199 200 def remove_event(self, uid, recurrenceid=None): 201 return self.store.remove_event(self.user, uid, recurrenceid) 202 203 class ResourceClient(Resource, Client): 204 205 "A Web application resource and calendar client." 206 207 def __init__(self, resource=None): 208 Resource.__init__(self, resource) 209 user = self.env.get_user() 210 Client.__init__(self, user and get_uri(user) or None) 211 212 class ResourceClientForObject(Resource, ClientForObject): 213 214 "A Web application resource and calendar client for a specific object." 215 216 def __init__(self, resource=None): 217 Resource.__init__(self, resource) 218 user = self.env.get_user() 219 ClientForObject.__init__(self, None, user and get_uri(user) or None) 220 221 class FormUtilities: 222 223 "Utility methods resource mix-in." 224 225 def control(self, name, type, value, selected=False, **kw): 226 227 """ 228 Show a control with the given 'name', 'type' and 'value', with 229 'selected' indicating whether it should be selected (checked or 230 equivalent), and with keyword arguments setting other properties. 231 """ 232 233 page = self.page 234 if selected: 235 page.input(name=name, type=type, value=value, checked=selected, **kw) 236 else: 237 page.input(name=name, type=type, value=value, **kw) 238 239 def menu(self, name, default, items, class_="", index=None): 240 241 """ 242 Show a select menu having the given 'name', set to the given 'default', 243 providing the given (value, label) 'items', and employing the given CSS 244 'class_' if specified. 245 """ 246 247 page = self.page 248 values = self.env.get_args().get(name, [default]) 249 if index is not None: 250 values = values[index:] 251 values = values and values[0:1] or [default] 252 253 page.select(name=name, class_=class_) 254 for v, label in items: 255 if v is None: 256 continue 257 if v in values: 258 page.option(label, value=v, selected="selected") 259 else: 260 page.option(label, value=v) 261 page.select.close() 262 263 def date_controls(self, name, default, index=None, show_tzid=True, read_only=False): 264 265 """ 266 Show date controls for a field with the given 'name' and 'default' form 267 date value. 268 269 If 'index' is specified, default field values will be overridden by the 270 element from a collection of existing form values with the specified 271 index; otherwise, field values will be overridden by a single form 272 value. 273 274 If 'show_tzid' is set to a false value, the time zone menu will not be 275 provided. 276 277 If 'read_only' is set to a true value, the controls will be hidden and 278 labels will be employed instead. 279 """ 280 281 page = self.page 282 283 # Show dates for up to one week around the current date. 284 285 dt = default.as_datetime() 286 if not dt: 287 dt = date.today() 288 289 base = to_date(dt) 290 291 # Show a date label with a hidden field if read-only. 292 293 if read_only: 294 self.control("%s-date" % name, "hidden", format_datetime(base)) 295 page.span(self.format_date(base, "long")) 296 297 # Show dates for up to one week around the current date. 298 # NOTE: Support paging to other dates. 299 300 else: 301 items = [] 302 for i in range(-7, 8): 303 d = base + timedelta(i) 304 items.append((format_datetime(d), self.format_date(d, "full"))) 305 self.menu("%s-date" % name, format_datetime(base), items, index=index) 306 307 # Show time details. 308 309 page.span(class_="time enabled") 310 311 if read_only: 312 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 313 self.control("%s-hour" % name, "hidden", default.get_hour()) 314 self.control("%s-minute" % name, "hidden", default.get_minute()) 315 self.control("%s-second" % name, "hidden", default.get_second()) 316 else: 317 self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 318 page.add(":") 319 self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 320 page.add(":") 321 self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 322 323 # Show time zone details. 324 325 if show_tzid: 326 page.add(" ") 327 tzid = default.get_tzid() or self.get_tzid() 328 329 # Show a label if read-only or a menu otherwise. 330 331 if read_only: 332 self.control("%s-tzid" % name, "hidden", tzid) 333 page.span(tzid) 334 else: 335 self.timezone_menu("%s-tzid" % name, tzid, index) 336 337 page.span.close() 338 339 def timezone_menu(self, name, default, index=None): 340 341 """ 342 Show timezone controls using a menu with the given 'name', set to the 343 given 'default' unless a field of the given 'name' provides a value. 344 """ 345 346 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 347 self.menu(name, default, entries, index=index) 348 349 class DateTimeFormUtilities: 350 351 "Date/time control methods resource mix-in." 352 353 # Control naming helpers. 354 355 def element_identifier(self, name, index=None): 356 return index is not None and "%s-%d" % (name, index) or name 357 358 def element_name(self, name, suffix, index=None): 359 return index is not None and "%s-%s" % (name, suffix) or name 360 361 def element_enable(self, index=None): 362 return index is not None and str(index) or "enable" 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