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