1import itertools
2
3from wtforms.utils import unset_value
4
5from .. import widgets
6from .core import Field
7from .core import UnboundField
8
9__all__ = ("FieldList",)
10
11
12class FieldList(Field):
13 """
14 Encapsulate an ordered list of multiple instances of the same field type,
15 keeping data as a list.
16
17 >>> authors = FieldList(StringField('Name', [validators.DataRequired()]))
18
19 :param unbound_field:
20 A partially-instantiated field definition, just like that would be
21 defined on a form directly.
22 :param min_entries:
23 if provided, always have at least this many entries on the field,
24 creating blank ones if the provided input does not specify a sufficient
25 amount.
26 :param max_entries:
27 accept no more than this many entries as input, even if more exist in
28 formdata.
29 :param separator:
30 A string which will be suffixed to this field's name to create the
31 prefix to enclosed list entries. The default is fine for most uses.
32 """
33
34 widget = widgets.ListWidget()
35
36 def __init__(
37 self,
38 unbound_field,
39 label=None,
40 validators=None,
41 min_entries=0,
42 max_entries=None,
43 separator="-",
44 default=(),
45 **kwargs,
46 ):
47 super().__init__(label, validators, default=default, **kwargs)
48 if self.filters:
49 raise TypeError(
50 "FieldList does not accept any filters. Instead, define"
51 " them on the enclosed field."
52 )
53 assert isinstance(
54 unbound_field, UnboundField
55 ), "Field must be unbound, not a field class"
56 self.unbound_field = unbound_field
57 self.min_entries = min_entries
58 self.max_entries = max_entries
59 self.last_index = -1
60 self._prefix = kwargs.get("_prefix", "")
61 self._separator = separator
62 self._field_separator = unbound_field.kwargs.get("separator", "-")
63
64 def process(self, formdata, data=unset_value, extra_filters=None):
65 if extra_filters:
66 raise TypeError(
67 "FieldList does not accept any filters. Instead, define"
68 " them on the enclosed field."
69 )
70
71 self.entries = []
72 if data is unset_value or not data:
73 try:
74 data = self.default()
75 except TypeError:
76 data = self.default
77
78 self.object_data = data
79
80 if formdata:
81 indices = sorted(set(self._extract_indices(self.name, formdata)))
82 if self.max_entries:
83 indices = indices[: self.max_entries]
84
85 idata = iter(data)
86 for index in indices:
87 try:
88 obj_data = next(idata)
89 except StopIteration:
90 obj_data = unset_value
91 self._add_entry(formdata, obj_data, index=index)
92 else:
93 for obj_data in data:
94 self._add_entry(formdata, obj_data)
95
96 while len(self.entries) < self.min_entries:
97 self._add_entry(formdata)
98
99 def _extract_indices(self, prefix, formdata):
100 """
101 Yield indices of any keys with given prefix.
102
103 formdata must be an object which will produce keys when iterated. For
104 example, if field 'foo' contains keys 'foo-0-bar', 'foo-1-baz', then
105 the numbers 0 and 1 will be yielded, but not necessarily in order.
106 """
107 offset = len(prefix) + 1
108 for k in formdata:
109 if k.startswith(prefix):
110 k = k[offset:].split(self._field_separator, 1)[0]
111 if k.isdigit():
112 yield int(k)
113
114 def validate(self, form, extra_validators=()):
115 """
116 Validate this FieldList.
117
118 Note that FieldList validation differs from normal field validation in
119 that FieldList validates all its enclosed fields first before running any
120 of its own validators.
121 """
122 self.errors = []
123
124 # Run validators on all entries within
125 for subfield in self.entries:
126 subfield.validate(form)
127 self.errors.append(subfield.errors)
128
129 if not any(x for x in self.errors):
130 self.errors = []
131
132 chain = itertools.chain(self.validators, extra_validators)
133 self._run_validation_chain(form, chain)
134
135 return len(self.errors) == 0
136
137 def populate_obj(self, obj, name):
138 values = getattr(obj, name, None)
139 try:
140 ivalues = iter(values)
141 except TypeError:
142 ivalues = iter([])
143
144 candidates = itertools.chain(ivalues, itertools.repeat(None))
145 _fake = type("_fake", (object,), {})
146 output = []
147 for field, data in zip(self.entries, candidates):
148 fake_obj = _fake()
149 fake_obj.data = data
150 field.populate_obj(fake_obj, "data")
151 output.append(fake_obj.data)
152
153 setattr(obj, name, output)
154
155 def _add_entry(self, formdata=None, data=unset_value, index=None):
156 assert (
157 not self.max_entries or len(self.entries) < self.max_entries
158 ), "You cannot have more than max_entries entries in this FieldList"
159 if index is None:
160 index = self.last_index + 1
161 self.last_index = index
162 name = f"{self.short_name}{self._separator}{index}"
163 id = f"{self.id}{self._separator}{index}"
164 field = self.unbound_field.bind(
165 form=None,
166 name=name,
167 prefix=self._prefix,
168 id=id,
169 _meta=self.meta,
170 translations=self._translations,
171 )
172 field.process(formdata, data)
173 self.entries.append(field)
174 return field
175
176 def append_entry(self, data=unset_value):
177 """
178 Create a new entry with optional default data.
179
180 Entries added in this way will *not* receive formdata however, and can
181 only receive object data.
182 """
183 return self._add_entry(data=data)
184
185 def pop_entry(self):
186 """Removes the last entry from the list and returns it."""
187 entry = self.entries.pop()
188 self.last_index -= 1
189 return entry
190
191 def __iter__(self):
192 return iter(self.entries)
193
194 def __len__(self):
195 return len(self.entries)
196
197 def __getitem__(self, index):
198 return self.entries[index]
199
200 @property
201 def data(self):
202 return [f.data for f in self.entries]