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