-
Notifications
You must be signed in to change notification settings - Fork 5
/
models.py
263 lines (218 loc) · 10.9 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
from __future__ import unicode_literals
import inspect
from django.core import exceptions
from django.core.exceptions import FieldError
from django.db.models import ForeignKey, ManyToManyField, OneToOneField
from django.db.models.base import Model
from django.db.models.constants import LOOKUP_SEP
from django.db.models.fields import Field, FieldDoesNotExist
from django.db.models.fields.related import add_lazy_relation
from django.db.models.query import QuerySet
from django.db.models.signals import class_prepared
from django.forms.models import model_to_dict
from django.utils import six
from ..forms.fields import (
DynamicModelChoiceField, DynamicModelMultipleChoiceField,
)
from .query import CompositeQuerySet, dynamic_queryset_factory
class DynamicChoicesField(object):
def __init__(self, *args, **kwargs):
super(DynamicChoicesField, self).__init__(*args, **kwargs)
# Hack to bypass non iterable choices validation
if isinstance(self._choices, six.string_types) or callable(self._choices):
self._choices_callback = self._choices
self._choices = []
else:
self._choices_callback = None
self._choices_relationships = None
def contribute_to_class(self, cls, name):
super(DynamicChoicesField, self).contribute_to_class(cls, name)
if self._choices_callback is not None:
class_prepared.connect(self.__validate_definition, sender=cls)
def __validate_definition(self, *args, **kwargs):
def error(message):
raise FieldError("%s: %s: %s" % (self.model._meta, self.name, message))
original_choices_callback = self._choices_callback
# The choices we're defined by a string
# therefore it should be a cls method
if isinstance(self._choices_callback, six.string_types):
callback = getattr(self.model, self._choices_callback, None)
if not callable(callback):
error('Cannot find method specified by choices.')
args_length = 2 # Since the callback is a method we must emulate the 'self'
self._choices_callback = callback
self._choices_callback_requires_instance = True
else:
args_length = 1 # It's a callable, it needs no reference to model instance
self._choices_callback_requires_instance = False
spec = inspect.getargspec(self._choices_callback)
# Make sure the callback has the correct number or arg
if spec.defaults is not None:
spec_defaults_len = len(spec.defaults)
args_length += spec_defaults_len
self._choices_relationships = spec.args[-spec_defaults_len:]
else:
self._choices_relationships = []
if len(spec.args) != args_length:
error('Specified choices callback must accept only a single arg')
self._choices_callback_field_descriptors = {}
# We make sure field descriptors are valid
for descriptor in self._choices_relationships:
lookups = descriptor.split(LOOKUP_SEP)
meta = self.model._meta
depth = len(lookups)
step = 1
fields = []
for lookup in lookups:
try:
field = meta.get_field(lookup)
# The field is a foreign key to another model
if isinstance(field, ForeignKey):
try:
meta = field.rel.to._meta
except AttributeError:
# The model hasn't been loaded yet
# so we must stop here and start over
# when it is loaded.
if isinstance(field.rel.to, six.string_types):
self._choices_callback = original_choices_callback
return add_lazy_relation(field.model, field,
field.rel.to,
self.__validate_definition)
else:
raise
step += 1
# We cannot go deeper if it's not a model
elif step != depth:
error('Invalid descriptor "%s", "%s" is not a ForeignKey to a model' % (
LOOKUP_SEP.join(lookups), LOOKUP_SEP.join(lookups[:step])))
fields.append(field)
except FieldDoesNotExist:
# Lookup failed, suggest alternatives
depth_descriptor = LOOKUP_SEP.join(descriptor[:step - 1])
if depth_descriptor:
depth_descriptor += LOOKUP_SEP
choice_descriptors = [(depth_descriptor + name) for name in meta.get_all_field_names()]
error('Invalid descriptor "%s", choices are %s' % (
LOOKUP_SEP.join(descriptor), ', '.join(choice_descriptors)))
self._choices_callback_field_descriptors[descriptor] = fields
@property
def has_choices_callback(self):
return callable(self._choices_callback)
@property
def choices_relationships(self):
return self._choices_relationships
def _invoke_choices_callback(self, model_instance, qs, data):
args = [qs]
# Make sure we pass the instance if the callback is a class method
if self._choices_callback_requires_instance:
args.insert(0, model_instance)
values = {}
for descriptor, fields in self._choices_callback_field_descriptors.items():
depth = len(fields)
step = 1
lookup_data = data
value = None
# Direct lookup
# foo__bar in data
if descriptor in data:
value = data[descriptor]
step = depth
field = fields[-1]
else:
# We're going to try to lookup every step of the descriptor.
# We first try field1, then field1__field2, etc..
# When there's a match we start over with fieldmatch and set the lookup data
# to the matched value.
field_name = "%s"
for field in fields:
field_name = field_name % field.name
if field_name in lookup_data:
value = lookup_data[field_name]
if step != depth:
if isinstance(field, ManyToManyField):
# We cannot lookup in m2m, it must be the final step
break
elif isinstance(value, list):
value = value[0] # Make sure we've got a scalar
if isinstance(field, ForeignKey):
if not isinstance(value, Model):
try:
value = field.rel.to.objects.get(pk=value)
except Exception:
# Invalid object
break
lookup_data = model_to_dict(value)
field_name = "%s"
step += 1
elif step != depth:
field_name = "%s%s%s" % (field_name, LOOKUP_SEP, '%s')
# We reached descriptors depth
if step == depth:
if isinstance(value, list) and \
not isinstance(field, ManyToManyField):
value = value[0] # Make sure we've got a scalar if its not a m2m
# Attempt to cast value, if failed we don't assign since it's invalid
try:
values[descriptor] = field.to_python(value)
except:
pass
return self._choices_callback(*args, **values)
def __super(self):
# Dirty hack to allow both DynamicChoicesForeignKey and DynamicChoicesManyToManyField
# to inherit this behavior with multiple inheritance
for base in self.__class__.__bases__:
if issubclass(base, Field):
self.__super = lambda: base # cache
return base
raise Exception('Subclasses must inherit from atleast one subclass of django.db.fields.Field')
def formfield(self, **kwargs):
if self.has_choices_callback:
db = kwargs.pop('using', None)
qs = self.rel.to._default_manager.using(db).complex_filter(self.rel.limit_choices_to)
defaults = {
'using': db,
'form_class': self.form_class,
'queryset': dynamic_queryset_factory(qs, self)
}
defaults.update(kwargs)
else:
defaults = kwargs
return self.__super().formfield(self, **defaults)
class DynamicChoicesForeignKeyMixin(DynamicChoicesField):
def validate(self, value, model_instance):
if self.has_choices_callback:
if self.rel.parent_link or value is None:
return
data = model_to_dict(model_instance)
for field in model_instance._meta.fields:
try:
data[field.name] = getattr(model_instance, field.name)
except field.rel.to.DoesNotExist:
pass
if model_instance.pk:
for m2m in model_instance._meta.many_to_many:
data[m2m.name] = getattr(model_instance, m2m.name).all()
qs = self.rel.to._default_manager.filter(**{self.rel.field_name: value})
qs = qs.complex_filter(self.rel.limit_choices_to)
dcqs = self._invoke_choices_callback(model_instance, qs, data)
# If the choices are not a queryset we assume it's an iterable of couple
# of label and querysets.
if not isinstance(dcqs, QuerySet):
dcqs = CompositeQuerySet(qs[1] for qs in dcqs)
if not dcqs.exists():
raise exceptions.ValidationError(self.error_messages['invalid'] % {
'model': self.rel.to._meta.verbose_name, 'pk': value})
else:
super(DynamicChoicesForeignKey, self).validate(value, model_instance)
class DynamicChoicesForeignKey(DynamicChoicesForeignKeyMixin, ForeignKey):
form_class = DynamicModelChoiceField
class DynamicChoicesOneToOneField(DynamicChoicesForeignKeyMixin, OneToOneField):
form_class = DynamicModelChoiceField
class DynamicChoicesManyToManyField(DynamicChoicesField, ManyToManyField):
form_class = DynamicModelMultipleChoiceField
try:
from south.modelsinspector import add_introspection_rules
add_introspection_rules([], ['^dynamic_choices\.db\.models'])
except ImportError:
pass