Mercurial > libervia-backend
comparison src/tmp/wokkel/mam.py @ 1770:f525c272fd6d
tmp (mam): various improvments:
- updated behaviour and namespace to new urn:xmpp:mam:1
- fixed use of query_id in MAMQueryRequest/MAMClient
- added a couple of assertions/checks
- use try/except instead of hasAttribute
- do not use generateElementsNamed (use elements method instead)
- do not ignore missing elements
- queryFields return a data form instead of a raw element
- fixed bad use of namespace in several addElement
- IMAMResource.onArchiveRequest doesn't have to return a Deferred anymore (but may do)
- buildForm use data_form.Field class directly and doesn't use intermediate list anymore
- /!\ XEP-0313 plugin is temporarly broken
author | Goffi <goffi@goffi.org> |
---|---|
date | Sun, 03 Jan 2016 18:36:41 +0100 |
parents | 2d8fccec84e8 |
children | 0c21dafedd22 |
comparison
equal
deleted
inserted
replaced
1769:1fc6a380f4db | 1770:f525c272fd6d |
---|---|
1 # -*- coding: utf-8 -*- | 1 # -*- coding: utf-8 -*- |
2 # -*- test-case-name: wokkel.test.test_mam -*- | 2 # -*- test-case-name: wokkel.test.test_mam -*- |
3 # | 3 # |
4 # SàT Wokkel extension for Message Archive Management (XEP-0313) | 4 # SàT Wokkel extension for Message Archive Management (XEP-0313) |
5 # Copyright (C) 2015 Jérôme Poisson (goffi@goffi.org) | |
5 # Copyright (C) 2015 Adien Cossa (souliane@mailoo.org) | 6 # Copyright (C) 2015 Adien Cossa (souliane@mailoo.org) |
6 | 7 |
7 # This program is free software: you can redistribute it and/or modify | 8 # This program is free software: you can redistribute it and/or modify |
8 # it under the terms of the GNU Affero General Public License as published by | 9 # it under the terms of the GNU Affero General Public License as published by |
9 # the Free Software Foundation, either version 3 of the License, or | 10 # the Free Software Foundation, either version 3 of the License, or |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | 15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
15 # GNU Affero General Public License for more details. | 16 # GNU Affero General Public License for more details. |
16 | 17 |
17 # You should have received a copy of the GNU Affero General Public License | 18 # You should have received a copy of the GNU Affero General Public License |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | 19 # along with this program. If not, see <http://www.gnu.org/licenses/>. |
19 # | |
20 # Copyright (c) Adrien Cossa. | |
21 # See LICENSE for details. | |
22 | 20 |
23 """ | 21 """ |
24 XMPP Message Archive Management protocol. | 22 XMPP Message Archive Management protocol. |
25 | 23 |
26 This protocol is specified in | 24 This protocol is specified in |
32 from zope.interface import Interface, implements | 30 from zope.interface import Interface, implements |
33 | 31 |
34 from twisted.words.protocols.jabber.xmlstream import IQ, toResponse | 32 from twisted.words.protocols.jabber.xmlstream import IQ, toResponse |
35 from twisted.words.xish import domish | 33 from twisted.words.xish import domish |
36 from twisted.words.protocols.jabber import jid | 34 from twisted.words.protocols.jabber import jid |
35 from twisted.internet import defer | |
37 from twisted.python import log | 36 from twisted.python import log |
38 | 37 |
39 from wokkel.subprotocols import IQHandlerMixin, XMPPHandler | 38 from wokkel.subprotocols import IQHandlerMixin, XMPPHandler |
40 from wokkel import disco, data_form, delay | 39 from wokkel import disco, data_form, delay |
41 | 40 |
42 import rsm | 41 import rsm |
43 | 42 |
44 NS_MAM = 'urn:xmpp:mam:0' | 43 NS_MAM = 'urn:xmpp:mam:1' |
45 NS_FORWARD = 'urn:xmpp:forward:0' | 44 NS_FORWARD = 'urn:xmpp:forward:0' |
46 | 45 |
47 FIELDS_REQUEST = "/iq[@type='get']/query[@xmlns='%s']" % NS_MAM | 46 FIELDS_REQUEST = "/iq[@type='get']/query[@xmlns='%s']" % NS_MAM |
48 ARCHIVE_REQUEST = "/iq[@type='set']/query[@xmlns='%s']" % NS_MAM | 47 ARCHIVE_REQUEST = "/iq[@type='set']/query[@xmlns='%s']" % NS_MAM |
49 PREFS_GET_REQUEST = "/iq[@type='get']/prefs[@xmlns='%s']" % NS_MAM | 48 PREFS_GET_REQUEST = "/iq[@type='get']/prefs[@xmlns='%s']" % NS_MAM |
82 @ivar rsm: RSM request instance. | 81 @ivar rsm: RSM request instance. |
83 @itype rsm: L{rsm.RSMRequest} | 82 @itype rsm: L{rsm.RSMRequest} |
84 | 83 |
85 @ivar node: pubsub node id if querying a pubsub node, else None. | 84 @ivar node: pubsub node id if querying a pubsub node, else None. |
86 @itype form: C{unicode} | 85 @itype form: C{unicode} |
87 """ | 86 |
88 | 87 @ivar query_id: id to use to track the query |
89 def __init__(self, form=None, rsm_=None, node=None): | 88 @itype form: C{unicode} |
89 """ | |
90 | |
91 def __init__(self, form=None, rsm_=None, node=None, query_id=None): | |
90 if form is not None: | 92 if form is not None: |
91 assert form.formType == 'submit' | 93 assert form.formType == 'submit' |
94 assert form.formNamespace == NS_MAM | |
92 self.form = form | 95 self.form = form |
93 self.rsm = rsm_ | 96 self.rsm = rsm_ |
94 self.node = node | 97 self.node = node |
98 self.query_id = query_id | |
95 | 99 |
96 @classmethod | 100 @classmethod |
97 def parse(cls, element): | 101 def parse(cls, element): |
98 """Parse the DOM representation of a MAM <query/> request. | 102 """Parse the DOM representation of a MAM <query/> request. |
99 | 103 |
108 form = data_form.findForm(element, NS_MAM) | 112 form = data_form.findForm(element, NS_MAM) |
109 try: | 113 try: |
110 rsm_request = rsm.RSMRequest.parse(element) | 114 rsm_request = rsm.RSMRequest.parse(element) |
111 except rsm.RSMNotFoundError: | 115 except rsm.RSMNotFoundError: |
112 rsm_request = None | 116 rsm_request = None |
113 node = element['node'] if element.hasAttribute('node') else None | 117 node = element.getAttribute('node') |
114 return MAMQueryRequest(form, rsm_request, node) | 118 query_id = element.getAttribute('queryid') |
119 return MAMQueryRequest(form, rsm_request, node, query_id) | |
115 | 120 |
116 def toElement(self): | 121 def toElement(self): |
117 """ | 122 """ |
118 Return the DOM representation of this RSM <query/> request. | 123 Return the DOM representation of this RSM <query/> request. |
119 | 124 |
120 @rtype: L{Element<twisted.words.xish.domish.Element>} | 125 @rtype: L{Element<twisted.words.xish.domish.Element>} |
121 """ | 126 """ |
122 mam_elt = domish.Element((NS_MAM, 'query')) | 127 mam_elt = domish.Element((NS_MAM, 'query')) |
123 if self.node is not None: | 128 if self.node is not None: |
124 mam_elt['node'] = self.node | 129 mam_elt['node'] = self.node |
130 if self.query_id is not None: | |
131 mam_elt['queryid'] = self.query_id | |
125 if self.form is not None: | 132 if self.form is not None: |
126 mam_elt.addChild(self.form.toElement()) | 133 mam_elt.addChild(self.form.toElement()) |
127 if self.rsm is not None: | 134 if self.rsm is not None: |
128 mam_elt.addChild(self.rsm.toElement()) | 135 mam_elt.addChild(self.rsm.toElement()) |
129 | 136 |
156 | 163 |
157 @param never (list): A list of JID instances. | 164 @param never (list): A list of JID instances. |
158 @type never: C{list} | 165 @type never: C{list} |
159 """ | 166 """ |
160 | 167 |
161 def __init__(self, default=None, always=None, never=None): | 168 def __init__(self, default, always=None, never=None): |
162 if default is not None: | 169 assert default in ('always', 'never', 'roster') |
163 assert default in ('always', 'never', 'roster') | |
164 self.default = default | 170 self.default = default |
165 if always is not None: | 171 if always is not None: |
166 assert isinstance(always, list) | 172 assert isinstance(always, list) |
167 else: | 173 else: |
168 always = [] | 174 always = [] |
172 else: | 178 else: |
173 never = [] | 179 never = [] |
174 self.never = never | 180 self.never = never |
175 | 181 |
176 @classmethod | 182 @classmethod |
177 def parse(cls, element): | 183 def parse(cls, prefs_elt): |
178 """Parse the DOM representation of a MAM <prefs/> request. | 184 """Parse the DOM representation of a MAM <prefs/> request. |
179 | 185 |
180 @param element: MAM <prefs/> request element. | 186 @param prefs_elt: MAM <prefs/> request element. |
181 @type element: L{Element<twisted.words.xish.domish.Element>} | 187 @type prefs_elt: L{Element<twisted.words.xish.domish.Element>} |
182 | 188 |
183 @return: MAMPrefs instance. | 189 @return: MAMPrefs instance. |
184 @rtype: L{MAMPrefs} | 190 @rtype: L{MAMPrefs} |
185 """ | 191 """ |
186 if element.uri != NS_MAM or element.name != 'prefs': | 192 if prefs_elt.uri != NS_MAM or prefs_elt.name != 'prefs': |
187 raise MAMError('Element provided is not a MAM <prefs/> request') | 193 raise MAMError('Element provided is not a MAM <prefs/> request') |
188 default = element['default'] if element.hasAttribute('default') else None | 194 try: |
195 default = prefs_elt['default'] | |
196 except KeyError: | |
197 # FIXME: return proper error here | |
198 raise MAMError('Element provided is not a valid MAM <prefs/> request') | |
199 | |
189 prefs = {} | 200 prefs = {} |
190 for attr in ('always', 'never'): | 201 for attr in ('always', 'never'): |
191 prefs[attr] = [] | 202 prefs[attr] = [] |
192 try: | 203 try: |
193 pref = domish.generateElementsNamed(element.elements(), attr).next() | 204 pref = prefs_elt.elements(NS_MAM, attr).next() |
194 for jid_s in domish.generateElementsNamed(pref.elements(), 'jid'): | 205 except StopIteration: |
206 # FIXME: return proper error here | |
207 raise MAMError('Element provided is not a valid MAM <prefs/> request') | |
208 else: | |
209 for jid_s in pref.elements(NS_MAM, 'jid'): | |
195 prefs[attr].append(jid.JID(jid_s)) | 210 prefs[attr].append(jid.JID(jid_s)) |
196 except StopIteration: | |
197 pass | |
198 return MAMPrefs(default, **prefs) | 211 return MAMPrefs(default, **prefs) |
199 | 212 |
200 def toElement(self): | 213 def toElement(self): |
201 """ | 214 """ |
202 Return the DOM representation of this RSM <prefs/>request. | 215 Return the DOM representation of this RSM <prefs/>request. |
233 MAM client. | 246 MAM client. |
234 | 247 |
235 This handler implements the protocol for sending out MAM requests. | 248 This handler implements the protocol for sending out MAM requests. |
236 """ | 249 """ |
237 | 250 |
238 def queryArchive(self, service=None, form=None, rsm=None, node=None, sender=None): | 251 def queryArchive(self, service=None, form=None, rsm=None, node=None, sender=None, query_id=None): |
239 """Query a user, MUC or pubsub archive. | 252 """Query a user, MUC or pubsub archive. |
240 | 253 |
241 @param service: Entity offering the MAM service (None for user archives). | 254 @param service: Entity offering the MAM service (None for user archives). |
242 @type service: L{JID<twisted.words.protocols.jabber.jid.JID>} | 255 @type service: L{JID<twisted.words.protocols.jabber.jid.JID>} |
243 | 256 |
251 @type node: C{unicode} | 264 @type node: C{unicode} |
252 | 265 |
253 @param sender: Optional sender address. | 266 @param sender: Optional sender address. |
254 @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>} | 267 @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>} |
255 | 268 |
269 @param query_id: Optional query id | |
270 @type query_id: C{unicode} | |
271 | |
256 @return: A deferred that fires upon receiving a response. | 272 @return: A deferred that fires upon receiving a response. |
257 @rtype: L{Deferred<twisted.internet.defer.Deferred>} | 273 @rtype: L{Deferred<twisted.internet.defer.Deferred>} |
258 """ | 274 """ |
259 iq = IQ(self.xmlstream, 'set') | 275 iq = IQ(self.xmlstream, 'set') |
260 MAMQueryRequest(form, rsm, node).render(iq) | 276 MAMQueryRequest(form, rsm, node, query_id).render(iq) |
261 if sender is not None: | 277 if sender is not None: |
262 iq['from'] = unicode(sender) | 278 iq['from'] = unicode(sender) |
263 return iq.send(to=service.full() if service else None) | 279 return iq.send(to=service.full() if service else None) |
264 | 280 |
265 def queryFields(self, service=None, sender=None): | 281 def queryFields(self, service=None, sender=None): |
269 @type service: L{JID<twisted.words.protocols.jabber.jid.JID>} | 285 @type service: L{JID<twisted.words.protocols.jabber.jid.JID>} |
270 | 286 |
271 @param sender: Optional sender address. | 287 @param sender: Optional sender address. |
272 @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>} | 288 @type sender: L{JID<twisted.words.protocols.jabber.jid.JID>} |
273 | 289 |
274 @return: A deferred that fires upon receiving a response. | 290 @return: data Form with the fields, or None if not found |
275 @rtype: L{Deferred<twisted.internet.defer.Deferred>} | 291 @rtype: L{Deferred<twisted.internet.defer.Deferred>} |
276 """ | 292 """ |
277 # http://xmpp.org/extensions/xep-0313.html#query-form | 293 # http://xmpp.org/extensions/xep-0313.html#query-form |
278 iq = IQ(self.xmlstream, 'get') | 294 iq = IQ(self.xmlstream, 'get') |
279 MAMQueryRequest().render(iq) | 295 MAMQueryRequest().render(iq) |
280 if sender is not None: | 296 if sender is not None: |
281 iq['from'] = unicode(sender) | 297 iq['from'] = unicode(sender) |
282 return iq.send(to=service.full() if service else None) | 298 d = iq.send(to=service.full() if service else None) |
299 d.addCallback(lambda iq_result: iq_result.elements(NS_MAM, 'query').next()) | |
300 d.addCallback(data_form.findForm, NS_MAM) | |
301 return d | |
283 | 302 |
284 def queryPrefs(self, service=None, sender=None): | 303 def queryPrefs(self, service=None, sender=None): |
285 """Retrieve the current user preferences. | 304 """Retrieve the current user preferences. |
286 | 305 |
287 @param service: Entity offering the MAM service (None for user archives). | 306 @param service: Entity offering the MAM service (None for user archives). |
405 """ | 424 """ |
406 @param resource: instance implementing IMAMResource | 425 @param resource: instance implementing IMAMResource |
407 @type resource: L{object} | 426 @type resource: L{object} |
408 """ | 427 """ |
409 self.resource = resource | 428 self.resource = resource |
410 self.extra_filters = {} | 429 self.extra_fields = {} |
411 | 430 |
412 def connectionInitialized(self): | 431 def connectionInitialized(self): |
413 """ | 432 """ |
414 Called when the XML stream has been initialized. | 433 Called when the XML stream has been initialized. |
415 | 434 |
422 | 441 |
423 def addFilter(self, field): | 442 def addFilter(self, field): |
424 """ | 443 """ |
425 Add a new filter for querying MAM archive. | 444 Add a new filter for querying MAM archive. |
426 | 445 |
427 @param field: dictionary specifying the attributes to build a | 446 @param field: data form field of the filter |
428 wokkel.data_form.Field. | 447 @type field: L{Form<wokkel.data_form.Field>} |
429 @type field: C{dict} | 448 """ |
430 """ | 449 self.extra_fields[field.var] = field |
431 self.extra_filters[field.var] = field | |
432 | 450 |
433 def _onFieldsRequest(self, iq): | 451 def _onFieldsRequest(self, iq): |
434 """ | 452 """ |
435 Called when a fields request has been received. | 453 Called when a fields request has been received. |
436 | 454 |
437 This immediately replies with a result response. | 455 This immediately replies with a result response. |
438 """ | 456 """ |
439 response = toResponse(iq, 'result') | 457 response = toResponse(iq, 'result') |
440 query = response.addElement((NS_MAM, 'query')) | 458 query = response.addElement((NS_MAM, 'query')) |
441 query.addChild(buildForm('form', extra=self.extra_filters).toElement()) | 459 query.addChild(buildForm('form', extra=self.extra_fields).toElement()) |
442 self.xmlstream.send(response) | 460 self.xmlstream.send(response) |
443 iq.handled = True | 461 iq.handled = True |
444 | 462 |
445 def _onArchiveRequest(self, iq): | 463 def _onArchiveRequest(self, iq): |
446 """ | 464 """ |
447 Called when a message archive request has been received. | 465 Called when a message archive request has been received. |
448 | 466 |
449 This immediately replies with a result response, followed by the | 467 This replies with the list of archived message and the <iq> result |
450 list of archived message and the finally the <fin/> message. | 468 @return: A tuple with list of message data (id, element, data) and RSM element |
451 """ | 469 @rtype: C{tuple} |
452 response = toResponse(iq, 'result') | 470 """ |
453 self.xmlstream.send(response) | |
454 | |
455 mam_ = MAMQueryRequest.parse(iq.query) | 471 mam_ = MAMQueryRequest.parse(iq.query) |
456 requestor = jid.JID(iq['from']) | 472 requestor = jid.JID(iq['from']) |
457 | 473 |
458 # remove unsupported filters | 474 # remove unsupported filters |
459 unsupported_fields = [] | 475 unsupported_fields = [] |
460 if mam_.form: | 476 if mam_.form: |
461 for key, field in mam_.form.fields.iteritems(): | 477 for key, field in mam_.form.fields.iteritems(): |
462 if key not in self._legacyFilters and key not in self.extra_filters: | 478 if key not in self._legacyFilters and key not in self.extra_fields: |
463 log.msg('Ignored unsupported MAM filter: %s' % field) | 479 log.msg('Ignored unsupported MAM filter: %s' % field) |
464 unsupported_fields.append(key) | 480 unsupported_fields.append(key) |
465 for key in unsupported_fields: | 481 for key in unsupported_fields: |
466 del mam_.form.fields[key] | 482 del mam_.form.fields[key] |
467 | 483 |
468 def forward_message(id_, elt, date): | 484 def forward_message(id_, elt, date): |
469 msg = domish.Element((None, 'message')) | 485 msg = domish.Element((None, 'message')) |
470 msg['to'] = iq['from'] | 486 msg['to'] = iq['from'] |
471 result = msg.addElement('result', NS_MAM) | 487 result = msg.addElement((NS_MAM, 'result')) |
472 if iq.hasAttribute('queryid'): | 488 try: |
473 result['queryid'] = iq.query['queryid'] | 489 result['queryid'] = iq.query['queryid'] |
490 except KeyError: | |
491 pass | |
474 result['id'] = id_ | 492 result['id'] = id_ |
475 forward = result.addElement('forwarded', NS_FORWARD) | 493 forward = result.addElement((NS_FORWARD, 'forwarded')) |
476 forward.addChild(delay.Delay(date).toElement()) | 494 forward.addChild(delay.Delay(date).toElement()) |
477 forward.addChild(elt) | 495 forward.addChild(elt) |
478 self.xmlstream.send(msg) | 496 self.xmlstream.send(msg) |
479 | 497 |
480 def cb(result): | 498 def cb(result): |
481 msg_data, rsm_elt = result | 499 msg_data, rsm_elt = result |
482 for data in msg_data: | 500 for data in msg_data: |
483 forward_message(*data) | 501 forward_message(*data) |
484 msg = domish.Element((None, 'message')) | 502 |
485 msg['to'] = iq['from'] | 503 response = toResponse(iq, 'result') |
486 fin = msg.addElement('fin', NS_MAM) | 504 fin = response.addElement((NS_MAM, 'fin')) |
487 if iq.hasAttribute('queryid'): | 505 |
488 fin['queryid'] = iq.query['queryid'] | |
489 if rsm_elt is not None: | 506 if rsm_elt is not None: |
490 fin.addChild(rsm_elt) | 507 fin.addChild(rsm_elt) |
491 self.xmlstream.send(msg) | 508 self.xmlstream.send(response) |
492 | 509 |
493 self.resource.onArchiveRequest(mam_, requestor).addCallback(cb) | 510 d = defer.maybeDeferred(self.resource.onArchiveRequest, mam_, requestor) |
511 d.addCallback(cb) | |
494 iq.handled = True | 512 iq.handled = True |
495 | 513 |
496 def _onPrefsGetRequest(self, iq): | 514 def _onPrefsGetRequest(self, iq): |
497 """ | 515 """ |
498 Called when a prefs get request has been received. | 516 Called when a prefs get request has been received. |
548 """ | 566 """ |
549 stampFormat = '%Y-%m-%dT%H:%M:%SZ' | 567 stampFormat = '%Y-%m-%dT%H:%M:%SZ' |
550 return datetime.astimezone(tzutc()).strftime(stampFormat) | 568 return datetime.astimezone(tzutc()).strftime(stampFormat) |
551 | 569 |
552 | 570 |
553 def buildForm(formType='submit', start=None, end=None, with_jid=None, extra=None): | 571 def buildForm(formType='submit', start=None, end=None, with_jid=None, extra_fields=None): |
554 """Prepare a Data Form for MAM. | 572 """Prepare a Data Form for MAM. |
555 | 573 |
556 @param formType: The type of the Data Form ('submit' or 'form'). | 574 @param formType: The type of the Data Form ('submit' or 'form'). |
557 @type formType: C{unicode} | 575 @type formType: C{unicode} |
558 | 576 |
563 @type end: L{datetime<datetime.datetime>} | 581 @type end: L{datetime<datetime.datetime>} |
564 | 582 |
565 @param with_jid: JID against which to match messages. | 583 @param with_jid: JID against which to match messages. |
566 @type with_jid: L{JID<twisted.words.protocols.jabber.jid.JID>} | 584 @type with_jid: L{JID<twisted.words.protocols.jabber.jid.JID>} |
567 | 585 |
568 @param extra: list of extra fields that are not defined by the | 586 @param extra_fields: list of extra data form fields that are not defined by the |
569 specification. Each element of the list must be a dictionary | 587 specification. |
570 specifying the attributes to build a wokkel.data_form.Field. | |
571 @type: C{list} | 588 @type: C{list} |
572 | 589 |
573 @return: XEP-0004 Data Form object. | 590 @return: XEP-0004 Data Form object. |
574 @rtype: L{Form<wokkel.data_form.Form>} | 591 @rtype: L{Form<wokkel.data_form.Form>} |
575 """ | 592 """ |
576 form = data_form.Form(formType, formNamespace=NS_MAM) | 593 form = data_form.Form(formType, formNamespace=NS_MAM) |
577 filters = [] | |
578 | 594 |
579 if formType == 'form': | 595 if formType == 'form': |
580 filters.extend(MAMService._legacyFilters.values()) | 596 for kwargs in MAMService._legacyFilters.values(): |
597 form.addField(data_form.Field(**kwargs)) | |
581 elif formType == 'submit': | 598 elif formType == 'submit': |
582 if start: | 599 if start: |
583 filters.append({'var': 'start', 'value': datetime2utc(start)}) | 600 form.addField(data_form.Field(var='start', value=datetime2utc(start))) |
584 if end: | 601 if end: |
585 filters.append({'var': 'end', 'value': datetime2utc(end)}) | 602 form.addField(data_form.Field(var='end', value=datetime2utc(end))) |
586 if with_jid: | 603 if with_jid: |
587 # must specify the field type to overwrite default value in Field.__init__ | 604 form.addField(data_form.Field(fieldType='jid-single', var='with', value=with_jid.full())) |
588 filters.append({'fieldType': 'jid-single', 'var': 'with', 'value': with_jid.full()}) | 605 |
589 | 606 if extra_fields is not None: |
590 if extra is not None: | 607 for field in extra_fields: |
591 filters.extend(extra) | 608 form.addField(field) |
592 | |
593 for field in filters: | |
594 form.addField(data_form.Field(**field)) | |
595 | 609 |
596 return form | 610 return form |