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