Skip to content

Commit 29f4f74

Browse files
author
Jan Schrewe
committed
Initial support for MapFields. Probably full of bugs and not too sure about the HTML
1 parent a3745c8 commit 29f4f74

File tree

3 files changed

+262
-34
lines changed

3 files changed

+262
-34
lines changed

mongodbforms/fieldgenerator.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from mongoengine import ReferenceField as MongoReferenceField, EmbeddedDocumentField as MongoEmbeddedDocumentField
2121

22-
from .fields import MongoCharField, ReferenceField, DocumentMultipleChoiceField, ListField
22+
from .fields import MongoCharField, ReferenceField, DocumentMultipleChoiceField, ListField, MapField
2323

2424
BLANK_CHOICE_DASH = [("", "---------")]
2525

@@ -257,8 +257,17 @@ def generate_listfield(self, field, **kwargs):
257257
defaults.update(kwargs)
258258
# figure out which type of field is stored in the list
259259
form_field = self.generate(field.field)
260-
f = ListField(form_field.__class__, **defaults)
261-
return f
260+
return ListField(form_field.__class__, **defaults)
261+
262+
def generate_mapfield(self, field, **kwargs):
263+
defaults = {
264+
'label': self.get_field_label(field),
265+
'help_text': self.get_field_help_text(field),
266+
'required': field.required
267+
}
268+
defaults.update(kwargs)
269+
form_field = self.generate(field.field)
270+
return MapField(form_field.__class__, **defaults)
262271

263272
def generate_filefield(self, field, **kwargs):
264273
defaults = {

mongodbforms/fields.py

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
from django import forms
9-
from django.core.validators import EMPTY_VALUES
9+
from django.core.validators import EMPTY_VALUES, MinLengthValidator, MaxLengthValidator
1010

1111
try:
1212
from django.utils.encoding import force_text as force_unicode
@@ -32,7 +32,7 @@
3232
from pymongo.objectid import ObjectId
3333
from pymongo.errors import InvalidId
3434

35-
from .widgets import ListWidget
35+
from .widgets import ListWidget, MapWidget
3636

3737
class MongoChoiceIterator(object):
3838
def __init__(self, field):
@@ -236,8 +236,6 @@ def clean(self, value):
236236

237237
field = self.field_type(required=self.required)
238238
for field_value in value:
239-
if self.required and field_value in self.empty_values:
240-
raise ValidationError(self.error_messages['required'])
241239
try:
242240
clean_data.append(field.clean(field_value))
243241
except ValidationError as e:
@@ -264,3 +262,136 @@ def _has_changed(self, initial, data):
264262
return True
265263
return False
266264

265+
class MapField(forms.Field):
266+
default_error_messages = {
267+
'invalid': _('Enter a list of values.'),
268+
'key_required': _('A key is required.'),
269+
}
270+
widget = MapWidget
271+
272+
def __init__(self, field_type, max_key_length=None, min_key_length=None,
273+
key_validators=[], field_kwargs={}, *args, **kwargs):
274+
275+
widget = field_type().widget
276+
if isinstance(widget, type):
277+
w_type = widget
278+
else:
279+
w_type = widget.__class__
280+
self.widget = self.widget(w_type)
281+
282+
super(MapField, self).__init__(*args, **kwargs)
283+
284+
self.key_validators = key_validators
285+
if min_key_length is not None:
286+
self.key_validators.append(MinLengthValidator(int(min_key_length)))
287+
if max_key_length is not None:
288+
self.key_validators.append(MaxLengthValidator(int(max_key_length)))
289+
290+
# type of field used to store the dicts value
291+
self.field_type = field_type
292+
field_kwargs['required'] = self.required
293+
self.field_kwargs = field_kwargs
294+
295+
if not hasattr(self, 'empty_values'):
296+
self.empty_values = list(EMPTY_VALUES)
297+
298+
def _validate_key(self, key):
299+
if key in self.empty_values and self.required:
300+
raise ValidationError(self.error_messages['key_required'], code='key_required')
301+
errors = []
302+
for v in self.key_validators:
303+
try:
304+
v(key)
305+
except ValidationError as e:
306+
if hasattr(e, 'code'):
307+
code = 'key_%s' % e.code
308+
if code in self.error_messages:
309+
e.message = self.error_messages[e.code]
310+
errors.extend(e.error_list)
311+
if errors:
312+
raise ValidationError(errors)
313+
314+
def validate(self, value):
315+
pass
316+
317+
def clean(self, value):
318+
"""
319+
Validates every value in the given list. A value is validated against
320+
the corresponding Field in self.fields.
321+
322+
For example, if this MultiValueField was instantiated with
323+
fields=(DateField(), TimeField()), clean() would call
324+
DateField.clean(value[0]) and TimeField.clean(value[1]).
325+
"""
326+
clean_data = {}
327+
errors = ErrorList()
328+
if not value or isinstance(value, dict):
329+
if not value or not [v for v in value.values() if v not in self.empty_values]:
330+
if self.required:
331+
raise ValidationError(self.error_messages['required'])
332+
else:
333+
return {}
334+
else:
335+
raise ValidationError(self.error_messages['invalid'])
336+
337+
# sort out required => at least one element must be in there
338+
339+
data_field = self.field_type(**self.field_kwargs)
340+
for key, val in value.items():
341+
# ignore empties. Can they even come up here?
342+
if key in self.empty_values and val in self.empty_values:
343+
continue
344+
345+
try:
346+
val = data_field.clean(val)
347+
except ValidationError as e:
348+
# Collect all validation errors in a single list, which we'll
349+
# raise at the end of clean(), rather than raising a single
350+
# exception for the first error we encounter.
351+
errors.extend(e.messages)
352+
353+
try:
354+
self._validate_key(key)
355+
except ValidationError as e:
356+
# Collect all validation errors in a single list, which we'll
357+
# raise at the end of clean(), rather than raising a single
358+
# exception for the first error we encounter.
359+
errors.extend(e.messages)
360+
361+
clean_data[key] = val
362+
363+
if data_field.required:
364+
data_field.required = False
365+
366+
if errors:
367+
raise ValidationError(errors)
368+
369+
self.validate(clean_data)
370+
self.run_validators(clean_data)
371+
return clean_data
372+
373+
def _has_changed(self, initial, data):
374+
field = self.field_type(**self.field_kwargs)
375+
for k, v in data.items():
376+
if initial is None:
377+
init_val = ''
378+
else:
379+
try:
380+
init_val = initial[k]
381+
except KeyError:
382+
return True
383+
if field._has_changed(init_val, v):
384+
return True
385+
return False
386+
387+
388+
389+
390+
391+
392+
393+
394+
395+
396+
397+

mongodbforms/widgets.py

Lines changed: 115 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import copy
22

3-
from django.forms.widgets import Widget, Media
3+
from django.forms.widgets import Widget, Media, TextInput
44
from django.utils.safestring import mark_safe
55
from django.core.validators import EMPTY_VALUES
6-
6+
from django.forms.util import flatatt
7+
from django.utils.html import format_html
78

89
class ListWidget(Widget):
910
"""
@@ -34,40 +35,23 @@ class ListWidget(Widget):
3435
"""
3536
def __init__(self, widget_type, attrs=None):
3637
self.widget_type = widget_type
37-
self.widgets = []
38+
self.widget = widget_type()
39+
if self.is_localized:
40+
self.widget.is_localized = self.is_localized
3841
super(ListWidget, self).__init__(attrs)
3942

4043
def render(self, name, value, attrs=None):
4144
if value is not None and not isinstance(value, (list, tuple)):
42-
raise TypeError("Value supplied for %s must be a list or tuple." % name)
43-
44-
# save the name should we need it later
45-
self._name = name
46-
47-
if value is not None:
48-
self.widgets = [self.widget_type() for v in value]
49-
50-
if value is None or (len(value[-1:]) == 0 or value[-1:][0] != ''):
51-
# there should be exactly one empty widget at the end of the list
52-
empty_widget = self.widget_type()
53-
empty_widget.is_required = False
54-
self.widgets.append(empty_widget)
55-
56-
if self.is_localized:
57-
for widget in self.widgets:
58-
widget.is_localized = self.is_localized
45+
raise TypeError("Value supplied for %s must be a list or tuple." % name)
5946

6047
output = []
6148
final_attrs = self.build_attrs(attrs)
6249
id_ = final_attrs.get('id', None)
63-
for i, widget in enumerate(self.widgets):
64-
try:
65-
widget_value = value[i]
66-
except (IndexError, TypeError):
67-
widget_value = None
50+
value.append('')
51+
for i, widget_value in enumerate(value):
6852
if id_:
6953
final_attrs = dict(final_attrs, id='%s_%s' % (id_, i))
70-
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs))
54+
output.append(self.widget.render(name + '_%s' % i, widget_value, final_attrs))
7155
return mark_safe(self.format_output(output))
7256

7357
def id_for_label(self, id_):
@@ -107,7 +91,7 @@ def _get_media(self):
10791

10892
def __deepcopy__(self, memo):
10993
obj = super(ListWidget, self).__deepcopy__(memo)
110-
obj.widgets = copy.deepcopy(self.widgets)
94+
obj.widget = copy.deepcopy(self.widget)
11195
obj.widget_type = copy.deepcopy(self.widget_type)
11296
return obj
11397

@@ -133,5 +117,109 @@ def _get_media(self):
133117
media = media + w.media
134118
return media
135119
media = property(_get_media)
120+
121+
122+
class MapWidget(Widget):
123+
"""
124+
A widget that is composed of multiple widgets.
125+
126+
Its render() method is different than other widgets', because it has to
127+
figure out how to split a single value for display in multiple widgets.
128+
The ``value`` argument can be one of two things:
129+
130+
* A list.
131+
* A normal value (e.g., a string) that has been "compressed" from
132+
a list of values.
133+
134+
In the second case -- i.e., if the value is NOT a list -- render() will
135+
first "decompress" the value into a list before rendering it. It does so by
136+
calling the decompress() method, which MultiWidget subclasses must
137+
implement. This method takes a single "compressed" value and returns a
138+
list.
139+
140+
When render() does its HTML rendering, each value in the list is rendered
141+
with the corresponding widget -- the first value is rendered in the first
142+
widget, the second value is rendered in the second widget, etc.
143+
144+
Subclasses may implement format_output(), which takes the list of rendered
145+
widgets and returns a string of HTML that formats them any way you'd like.
146+
147+
You'll probably want to use this class with MultiValueField.
148+
"""
149+
def __init__(self, widget_type, attrs=None):
150+
self.widget_type = widget_type
151+
self.key_widget = TextInput()
152+
self.key_widget.is_localized = self.is_localized
153+
self.data_widget = self.widget_type()
154+
self.data_widget.is_localized = self.is_localized
155+
super(MapWidget, self).__init__(attrs)
156+
157+
def render(self, name, value, attrs=None):
158+
if value is not None and not isinstance(value, dict):
159+
raise TypeError("Value supplied for %s must be a dict." % name)
160+
161+
output = []
162+
final_attrs = self.build_attrs(attrs)
163+
id_ = final_attrs.get('id', None)
164+
fieldset_attr = {}
136165

166+
value = value.items()
167+
value.append(('', ''))
168+
for i, (key, widget_value) in enumerate(value):
169+
if id_:
170+
final_attrs = dict(final_attrs, id='%s_%s' % (id_, i))
171+
fieldset_attr = dict(final_attrs, id='fieldset_%s_%s' % (id_, i))
172+
173+
group = []
174+
group.append(format_html('<fieldset{0}>', flatatt(fieldset_attr)))
175+
group.append(self.key_widget.render(name + '_key_%s' % i, key, final_attrs))
176+
group.append(self.data_widget.render(name + '_value_%s' % i, widget_value, final_attrs))
177+
group.append('</fieldset>')
178+
179+
output.append(mark_safe(''.join(group)))
180+
return mark_safe(self.format_output(output))
181+
182+
def id_for_label(self, id_):
183+
# See the comment for RadioSelect.id_for_label()
184+
if id_:
185+
id_ += '_0'
186+
return id_
187+
188+
def value_from_datadict(self, data, files, name):
189+
i = 0
190+
ret = {}
191+
while (name + '_key_%s' % i) in data:
192+
key = self.key_widget.value_from_datadict(data, files, name + '_key_%s' % i)
193+
value = self.data_widget.value_from_datadict(data, files, name + '_value_%s' % i)
194+
if key not in EMPTY_VALUES:
195+
ret.update(((key, value), ))
196+
i = i + 1
197+
return ret
198+
199+
def format_output(self, rendered_widgets):
200+
"""
201+
Given a list of rendered widgets (as strings), returns a Unicode string
202+
representing the HTML for the whole lot.
203+
204+
This hook allows you to format the HTML design of the widgets, if
205+
needed.
206+
"""
207+
return ''.join(rendered_widgets)
208+
209+
def _get_media(self):
210+
"Media for a multiwidget is the combination of all media of the subwidgets"
211+
media = Media()
212+
for w in self.widgets:
213+
media = media + w.media
214+
return media
215+
media = property(_get_media)
216+
217+
def __deepcopy__(self, memo):
218+
obj = super(MapWidget, self).__deepcopy__(memo)
219+
obj.widget_type = copy.deepcopy(self.widget_type)
220+
obj.key_widget = copy.deepcopy(self.key_widget)
221+
obj.data_widget = copy.deepcopy(self.data_widget)
222+
return obj
223+
224+
137225

0 commit comments

Comments
 (0)