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