Ticket #7018: metaforms.py

File metaforms.py, 5.7 KB (added by Mitar, 14 years ago)

Metaclass and mixin which allows multiple ModelForm inheritance

Line 
1from django.forms import forms
2from django.forms import models as forms_models
3
4def intersect(a, b):
5 """
6 Finds the intersection of two dictionaries.
7
8 A key and value pair is included in the result only if the key exists in both given dictionaries. Value is taken from
9 the second dictionary.
10 """
11
12 return dict(filter(lambda (x, y): x in a, b.items()))
13
14class ParentsIncludedModelFormMetaclass(forms_models.ModelFormMetaclass):
15 """
16 `django.forms.models.ModelFormMetaclass` produces only all declared fields of the current and parent clasess combined with
17 fields from the model as defined in `Meta` subclass which is taken from the first class which defines it (as `getattr` finds it).
18 This metaclass adds also all other fields of parent classes to the current class.
19
20 Workaround for http://code.djangoproject.com/ticket/7018.
21
22 It works only on parent classes and not all ancestor classes.
23
24 The order of fields could be probably improved. It would be much easier if `django.forms.models.ModelFormMetaclass` would
25 simply use `True` in `django.forms.forms.get_declared_fields`.
26
27 `Meta` (`self._meta` attribute) is not merged but taken from the first class which defines it (as `getattr` finds it).
28
29 Warning: This also means that all `django.forms.models.ModelForm` methods which operate on a model (like `save` and `clean`)
30 use only model from the first class which defines it. Use `ParentsIncludedModelFormMixin` for methods which operate also on
31 parent `django.forms.models.ModelForm` classes.
32
33 It should be used as a `__metaclass__` class of the given multi-parent form class.
34 """
35
36 def __new__(cls, name, bases, attrs):
37 # We store attrs as ModelFormMetaclass.__new__ clears all fields from it
38 attrs_copy = attrs.copy()
39 new_class = super(ParentsIncludedModelFormMetaclass, cls).__new__(cls, name, bases, attrs)
40 # All declared fields + model fields from parent classes
41 fields_without_current_model = forms.get_declared_fields(bases, attrs_copy, True)
42 new_class.base_fields.update(fields_without_current_model)
43 return new_class
44
45class ParentsIncludedModelFormMixin(object):
46 """
47 When combinining multiple forms based on `django.forms.models.ModelForm` into one form default methods operate only on the
48 first object instance (as `getattr` finds it) and also do not allow multiple initial instances to be passed to form
49 constructor. This mixin class redefines those methods to operate on multiple instances.
50
51 It should be listed as the parent class before `django.forms.models.ModelForm` based classes so that methods here take
52 precedence.
53 """
54
55 def __init__(self, *args, **kwargs):
56 """
57 Populates `self.instances` and `self.metas` with the given (or constructed empty) instances and `Meta` classes of the current
58 and parent (but not all ancestor) classes.
59
60 Based on `django.forms.models.BaseModelForm.__init__` method and extended for multiple instances.
61
62 Optional `instance` argument should be a list of all instances for the current and parent `django.forms.models.ModelForm`
63 classes with defined `Meta` class (with now required `model` attribute).
64 """
65
66 self.metas = []
67 if 'Meta' in self.__class__.__dict__:
68 # We add meta of the current class
69 self.metas.append(forms_models.ModelFormOptions(self.Meta))
70 # We add metas from parent classes
71 self.metas += [forms_models.ModelFormOptions(getattr(cls, 'Meta', None)) for cls in self.__class__.__bases__ if issubclass(cls, forms_models.ModelForm)]
72
73 instances = kwargs.pop('instance', None)
74 if instances is None:
75 for meta in self.metas:
76 if meta.model is None:
77 raise ValueError('ModelForm has no model class specified.')
78 self.instances = [meta.model() for meta in self.metas]
79 for instance in self.instances:
80 instance._adding = True
81 object_data = {}
82 else:
83 self.instances = instances
84 for instance in self.instances:
85 instance._adding = False
86 object_data = {}
87 if len(instances) != len(self.metas):
88 raise ValueError('Number of instances does not match number of metas.')
89 # We traverse in reverse order to keep in sync with get_declared_fields
90 for instance, meta in reversed(zip(self.instances, self.metas)):
91 object_data.update(forms_models.model_to_dict(instance, meta.fields, meta.exclude))
92
93 initial = kwargs.pop('initial', None)
94 if initial is not None:
95 object_data.update(initial)
96
97 super(ParentsIncludedModelFormMixin, self).__init__(initial=object_data, *args, **kwargs)
98
99 def _iterate_over_instances(self, method_name, *args, **kwargs):
100 """
101 Somewhat hackish implementation of a wrapper for multiple instances.
102
103 It temporary sets `self.instance` and `self._meta` and calls requested method. It collects possible results of this method calls
104 into the list and returns it. At the end it restores `self.instance` and `self._meta` to initial values.
105 """
106
107 # Save original values
108 original_instance = self.instance
109 original_meta = self._meta
110
111 results = []
112
113 for instance, meta in zip(self.instances, self.metas):
114 # Temporary set values
115 self.instance = instance
116 self._meta = meta
117 results.append(getattr(super(ParentsIncludedModelFormMixin, self), method_name)(*args, **kwargs))
118
119 # Restore original values
120 self.instance = original_instance
121 self._meta = original_meta
122
123 return results
124
125 def clean(self):
126 # We traverse in reverse order to keep in sync with get_declared_fields
127 return reduce(intersect, reversed(self._iterate_over_instances('clean')))
128
129 def _post_clean(self):
130 self._iterate_over_instances('_post_clean')
131
132 # We do not change validate_unique on purpose as it is called from _post_clean and we will probably do not use it otherwise
133
134 def save(self, commit=True):
135 return self._iterate_over_instances('save', commit)
136
137 save.alters_data = True
Back to Top