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