Ticket #7018: metaforms.py

File metaforms.py, 5.7 KB (added by mitar, 5 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