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_address, get_uri, uri_item, 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, messenger=None): 220 Resource.__init__(self, resource) 221 user = self.env.get_user() 222 ClientForObject.__init__(self, None, user and get_uri(user) or None, messenger) 223 224 # Communication methods. 225 226 def send_message(self, method, sender, from_organiser, parts=None): 227 228 """ 229 Create a full calendar object employing the given 'method', and send it 230 to the appropriate recipients, also sending a copy to the 'sender'. The 231 'from_organiser' value indicates whether the organiser is sending this 232 message (and is thus equivalent to "as organiser"). 233 """ 234 235 parts = parts or [self.obj.to_part(method)] 236 237 # As organiser, send an invitation to attendees, excluding oneself if 238 # also attending. The updated event will be saved by the outgoing 239 # handler. 240 241 organiser = get_uri(self.obj.get_value("ORGANIZER")) 242 attendees = uri_values(self.obj.get_values("ATTENDEE")) 243 244 if from_organiser: 245 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 246 else: 247 recipients = [get_address(organiser)] 248 249 # Since the outgoing handler updates this user's free/busy details, 250 # the stored details will probably not have the updated details at 251 # this point, so we update our copy for serialisation as the bundled 252 # free/busy object. 253 254 freebusy = self.store.get_freebusy(self.user) 255 self.update_freebusy(freebusy, self.user, from_organiser) 256 257 # Bundle free/busy information if appropriate. 258 259 part = self.get_freebusy_part(freebusy) 260 if part: 261 parts.append(part) 262 263 # Explicitly specify the outgoing BCC recipient since we are sending as 264 # the generic calendar user. 265 266 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 267 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 268 269 # Action methods. 270 271 def process_received_request(self): 272 273 """ 274 Process the current request for the current user. Return whether any 275 action was taken. 276 """ 277 278 # Reply only on behalf of this user. 279 280 attendee_attr = self.update_participation(self.obj) 281 282 if not attendee_attr: 283 return False 284 285 self.obj["ATTENDEE"] = [(self.user, attendee_attr)] 286 self.update_dtstamp() 287 self.set_sequence(False) 288 self.send_message("REPLY", get_address(self.user), from_organiser=False) 289 return True 290 291 def process_created_request(self, method, to_cancel=None, to_unschedule=None): 292 293 """ 294 Process the current request, sending a created request of the given 295 'method' to attendees. Return whether any action was taken. 296 297 If 'to_cancel' is specified, a list of participants to be sent cancel 298 messages is provided. 299 300 If 'to_unschedule' is specified, a list of periods to be unscheduled is 301 provided. 302 """ 303 304 # Here, the organiser should be the current user. 305 306 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 307 308 self.update_sender(organiser_attr) 309 self.update_dtstamp() 310 self.set_sequence(True) 311 312 parts = [self.obj.to_part(method)] 313 314 # Add message parts with cancelled occurrence information. 315 # NOTE: This could probably be merged with the updated event message. 316 317 if to_unschedule: 318 obj = self.obj.copy() 319 obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"]) 320 321 for p in to_unschedule: 322 if not p.origin: 323 continue 324 obj["RECURRENCE-ID"] = [(format_datetime(p.get_start()), p.get_start_attr())] 325 parts.append(obj.to_part("CANCEL")) 326 327 # Send the updated event, along with a cancellation for each of the 328 # unscheduled occurrences. 329 330 self.send_message("CANCEL", get_address(organiser), from_organiser=True, parts=parts) 331 332 # When cancelling, replace the attendees with those for whom the event 333 # is now cancelled. 334 335 if to_cancel: 336 obj = self.obj.copy() 337 obj["ATTENDEE"] = to_cancel 338 339 # Send a cancellation to all uninvited attendees. 340 341 self.send_message("CANCEL", get_address(organiser), from_organiser=True) 342 343 return True 344 345 class FormUtilities: 346 347 "Utility methods resource mix-in." 348 349 def control(self, name, type, value, selected=False, **kw): 350 351 """ 352 Show a control with the given 'name', 'type' and 'value', with 353 'selected' indicating whether it should be selected (checked or 354 equivalent), and with keyword arguments setting other properties. 355 """ 356 357 page = self.page 358 if type in ("checkbox", "radio") and selected: 359 page.input(name=name, type=type, value=value, checked=selected, **kw) 360 else: 361 page.input(name=name, type=type, value=value, **kw) 362 363 def menu(self, name, default, items, class_="", index=None): 364 365 """ 366 Show a select menu having the given 'name', set to the given 'default', 367 providing the given (value, label) 'items', and employing the given CSS 368 'class_' if specified. 369 """ 370 371 page = self.page 372 values = self.env.get_args().get(name, [default]) 373 if index is not None: 374 values = values[index:] 375 values = values and values[0:1] or [default] 376 377 page.select(name=name, class_=class_) 378 for v, label in items: 379 if v is None: 380 continue 381 if v in values: 382 page.option(label, value=v, selected="selected") 383 else: 384 page.option(label, value=v) 385 page.select.close() 386 387 def date_controls(self, name, default, index=None, show_tzid=True, read_only=False): 388 389 """ 390 Show date controls for a field with the given 'name' and 'default' form 391 date value. 392 393 If 'index' is specified, default field values will be overridden by the 394 element from a collection of existing form values with the specified 395 index; otherwise, field values will be overridden by a single form 396 value. 397 398 If 'show_tzid' is set to a false value, the time zone menu will not be 399 provided. 400 401 If 'read_only' is set to a true value, the controls will be hidden and 402 labels will be employed instead. 403 """ 404 405 page = self.page 406 407 # Show dates for up to one week around the current date. 408 409 dt = default.as_datetime() 410 if not dt: 411 dt = date.today() 412 413 base = to_date(dt) 414 415 # Show a date label with a hidden field if read-only. 416 417 if read_only: 418 self.control("%s-date" % name, "hidden", format_datetime(base)) 419 page.span(self.format_date(base, "long")) 420 421 # Show dates for up to one week around the current date. 422 # NOTE: Support paging to other dates. 423 424 else: 425 items = [] 426 for i in range(-7, 8): 427 d = base + timedelta(i) 428 items.append((format_datetime(d), self.format_date(d, "full"))) 429 self.menu("%s-date" % name, format_datetime(base), items, index=index) 430 431 # Show time details. 432 433 page.span(class_="time enabled") 434 435 if read_only: 436 page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) 437 self.control("%s-hour" % name, "hidden", default.get_hour()) 438 self.control("%s-minute" % name, "hidden", default.get_minute()) 439 self.control("%s-second" % name, "hidden", default.get_second()) 440 else: 441 self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) 442 page.add(":") 443 self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) 444 page.add(":") 445 self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) 446 447 # Show time zone details. 448 449 if show_tzid: 450 page.add(" ") 451 tzid = default.get_tzid() or self.get_tzid() 452 453 # Show a label if read-only or a menu otherwise. 454 455 if read_only: 456 self.control("%s-tzid" % name, "hidden", tzid) 457 page.span(tzid) 458 else: 459 self.timezone_menu("%s-tzid" % name, tzid, index) 460 461 page.span.close() 462 463 def timezone_menu(self, name, default, index=None): 464 465 """ 466 Show timezone controls using a menu with the given 'name', set to the 467 given 'default' unless a field of the given 'name' provides a value. 468 """ 469 470 entries = [(tzid, tzid) for tzid in pytz.all_timezones] 471 self.menu(name, default, entries, index=index) 472 473 class DateTimeFormUtilities: 474 475 "Date/time control methods resource mix-in." 476 477 # Control naming helpers. 478 479 def element_identifier(self, name, index=None): 480 return index is not None and "%s-%d" % (name, index) or name 481 482 def element_name(self, name, suffix, index=None): 483 return index is not None and "%s-%s" % (name, suffix) or name 484 485 def element_enable(self, index=None): 486 return index is not None and str(index) or "enable" 487 488 def show_object_datetime_controls(self, period, index=None): 489 490 """ 491 Show datetime-related controls if already active or if an object needs 492 them for the given 'period'. The given 'index' is used to parameterise 493 individual controls for dynamic manipulation. 494 """ 495 496 p = form_period_from_period(period) 497 498 page = self.page 499 args = self.env.get_args() 500 _id = self.element_identifier 501 _name = self.element_name 502 _enable = self.element_enable 503 504 # Add a dynamic stylesheet to permit the controls to modify the display. 505 # NOTE: The style details need to be coordinated with the static 506 # NOTE: stylesheet. 507 508 if index is not None: 509 page.style(type="text/css") 510 511 # Unlike the rules for object properties, these affect recurrence 512 # properties. 513 514 page.add("""\ 515 input#dttimes-enable-%(index)d, 516 input#dtend-enable-%(index)d, 517 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, 518 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, 519 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, 520 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { 521 display: none; 522 }""" % {"index" : index}) 523 524 page.style.close() 525 526 self.control( 527 _name("dtend-control", "recur", index), "checkbox", 528 _enable(index), p.end_enabled, 529 id=_id("dtend-enable", index) 530 ) 531 532 self.control( 533 _name("dttimes-control", "recur", index), "checkbox", 534 _enable(index), p.times_enabled, 535 id=_id("dttimes-enable", index) 536 ) 537 538 def show_datetime_controls(self, formdate, show_start): 539 540 """ 541 Show datetime details from the current object for the 'formdate', 542 showing start details if 'show_start' is set to a true value. Details 543 will appear as controls for organisers and labels for attendees. 544 """ 545 546 page = self.page 547 548 # Show controls for editing as organiser. 549 550 if self.is_organiser(): 551 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 552 553 if show_start: 554 page.div(class_="dt enabled") 555 self.date_controls("dtstart", formdate) 556 page.br() 557 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 558 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") 559 page.div.close() 560 561 else: 562 page.div(class_="dt disabled") 563 page.label("Specify end date", for_="dtend-enable", class_="enable") 564 page.div.close() 565 page.div(class_="dt enabled") 566 self.date_controls("dtend", formdate) 567 page.br() 568 page.label("End on same day", for_="dtend-enable", class_="disable") 569 page.div.close() 570 571 page.td.close() 572 573 # Show a label as attendee. 574 575 else: 576 dt = formdate.as_datetime() 577 if dt: 578 page.td(self.format_datetime(dt, "full")) 579 else: 580 page.td("(Unrecognised date)") 581 582 def show_recurrence_controls(self, index, period, recurrenceid, recurrenceids, show_start): 583 584 """ 585 Show datetime details from the current object for the recurrence having 586 the given 'index', with the recurrence period described by 'period', 587 indicating a start, end and origin of the period from the event details, 588 employing any 'recurrenceid' and 'recurrenceids' for the object to 589 configure the displayed information. 590 591 If 'show_start' is set to a true value, the start details will be shown; 592 otherwise, the end details will be shown. 593 """ 594 595 page = self.page 596 _id = self.element_identifier 597 _name = self.element_name 598 599 p = event_period_from_period(period) 600 replaced = not recurrenceid and p.is_replaced(recurrenceids) 601 602 # Show controls for editing as organiser. 603 604 if self.is_organiser() and not replaced: 605 page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) 606 607 read_only = period.origin == "RRULE" 608 609 if show_start: 610 page.div(class_="dt enabled") 611 self.date_controls(_name("dtstart", "recur", index), p.get_form_start(), index=index, read_only=read_only) 612 if not read_only: 613 page.br() 614 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") 615 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") 616 page.div.close() 617 618 # Put the origin somewhere. 619 620 self.control("recur-origin", "hidden", p.origin or "") 621 622 else: 623 page.div(class_="dt disabled") 624 if not read_only: 625 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") 626 page.div.close() 627 page.div(class_="dt enabled") 628 self.date_controls(_name("dtend", "recur", index), p.get_form_end(), index=index, show_tzid=False, read_only=read_only) 629 if not read_only: 630 page.br() 631 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") 632 page.div.close() 633 634 page.td.close() 635 636 # Show label as attendee. 637 638 else: 639 self.show_recurrence_label(p, recurrenceid, recurrenceids, show_start) 640 641 def show_recurrence_label(self, period, recurrenceid, recurrenceids, show_start): 642 643 """ 644 Show datetime details for the given 'period', employing any 645 'recurrenceid' and 'recurrenceids' for the object to configure the 646 displayed information. 647 648 If 'show_start' is set to a true value, the start details will be shown; 649 otherwise, the end details will be shown. 650 """ 651 652 page = self.page 653 654 p = event_period_from_period(period) 655 replaced = not recurrenceid and p.is_replaced(recurrenceids) 656 657 css = " ".join([ 658 replaced and "replaced" or "", 659 p.is_affected(recurrenceid) and "affected" or "" 660 ]) 661 662 formdate = show_start and p.get_form_start() or p.get_form_end() 663 dt = formdate.as_datetime() 664 if dt: 665 page.td(self.format_datetime(dt, "long"), class_=css) 666 else: 667 page.td("(Unrecognised date)") 668 669 def get_date_control_values(self, name, multiple=False, tzid_name=None): 670 671 """ 672 Return a form date object representing fields starting with 'name'. If 673 'multiple' is set to a true value, many date objects will be returned 674 corresponding to a collection of datetimes. 675 676 If 'tzid_name' is specified, the time zone information will be acquired 677 from fields starting with 'tzid_name' instead of 'name'. 678 """ 679 680 args = self.env.get_args() 681 682 dates = args.get("%s-date" % name, []) 683 hours = args.get("%s-hour" % name, []) 684 minutes = args.get("%s-minute" % name, []) 685 seconds = args.get("%s-second" % name, []) 686 tzids = args.get("%s-tzid" % (tzid_name or name), []) 687 688 # Handle absent values by employing None values. 689 690 field_values = map(None, dates, hours, minutes, seconds, tzids) 691 692 if not field_values and not multiple: 693 all_values = FormDate() 694 else: 695 all_values = [] 696 for date, hour, minute, second, tzid in field_values: 697 value = FormDate(date, hour, minute, second, tzid or self.get_tzid()) 698 699 # Return a single value or append to a collection of all values. 700 701 if not multiple: 702 return value 703 else: 704 all_values.append(value) 705 706 return all_values 707 708 def set_date_control_values(self, name, formdates, tzid_name=None): 709 710 """ 711 Replace form fields starting with 'name' using the values of the given 712 'formdates'. 713 714 If 'tzid_name' is specified, the time zone information will be stored in 715 fields starting with 'tzid_name' instead of 'name'. 716 """ 717 718 args = self.env.get_args() 719 720 args["%s-date" % name] = [d.date for d in formdates] 721 args["%s-hour" % name] = [d.hour for d in formdates] 722 args["%s-minute" % name] = [d.minute for d in formdates] 723 args["%s-second" % name] = [d.second for d in formdates] 724 args["%s-tzid" % (tzid_name or name)] = [d.tzid for d in formdates] 725 726 # vim: tabstop=4 expandtab shiftwidth=4