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