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