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