1"""
2"Rel objects" for related fields.
3
4"Rel objects" (for lack of a better name) carry information about the relation
5modeled by a related field and provide some utility functions. They're stored
6in the ``remote_field`` attribute of the field.
7
8They also act as reverse fields for the purposes of the Meta API because
9they're the closest concept currently available.
10"""
11
12from functools import cached_property
13
14from plain import exceptions
15from plain.utils.hashable import make_hashable
16
17from . import BLANK_CHOICE_DASH
18from .mixins import FieldCacheMixin
19
20
21class ForeignObjectRel(FieldCacheMixin):
22 """
23 Used by ForeignObject to store information about the relation.
24
25 ``_meta.get_fields()`` returns this class to provide access to the field
26 flags for the reverse relation.
27 """
28
29 # Field flags
30 auto_created = True
31 concrete = False
32 is_relation = True
33
34 # Reverse relations are always nullable (Plain can't enforce that a
35 # foreign key on the related model points to this model).
36 allow_null = True
37 empty_strings_allowed = False
38
39 def __init__(
40 self,
41 field,
42 to,
43 related_name=None,
44 related_query_name=None,
45 limit_choices_to=None,
46 parent_link=False,
47 on_delete=None,
48 ):
49 self.field = field
50 self.model = to
51 self.related_name = related_name
52 self.related_query_name = related_query_name
53 self.limit_choices_to = {} if limit_choices_to is None else limit_choices_to
54 self.parent_link = parent_link
55 self.on_delete = on_delete
56
57 self.symmetrical = False
58 self.multiple = True
59
60 # Some of the following cached_properties can't be initialized in
61 # __init__ as the field doesn't have its model yet. Calling these methods
62 # before field.contribute_to_class() has been called will result in
63 # AttributeError
64 @cached_property
65 def hidden(self):
66 return self.is_hidden()
67
68 @cached_property
69 def name(self):
70 return self.field.related_query_name()
71
72 @property
73 def remote_field(self):
74 return self.field
75
76 @property
77 def target_field(self):
78 """
79 When filtering against this relation, return the field on the remote
80 model against which the filtering should happen.
81 """
82 target_fields = self.path_infos[-1].target_fields
83 if len(target_fields) > 1:
84 raise exceptions.FieldError(
85 "Can't use target_field for multicolumn relations."
86 )
87 return target_fields[0]
88
89 @cached_property
90 def related_model(self):
91 if not self.field.model:
92 raise AttributeError(
93 "This property can't be accessed before self.field.contribute_to_class "
94 "has been called."
95 )
96 return self.field.model
97
98 @cached_property
99 def many_to_many(self):
100 return self.field.many_to_many
101
102 @cached_property
103 def many_to_one(self):
104 return self.field.one_to_many
105
106 @cached_property
107 def one_to_many(self):
108 return self.field.many_to_one
109
110 def get_lookup(self, lookup_name):
111 return self.field.get_lookup(lookup_name)
112
113 def get_internal_type(self):
114 return self.field.get_internal_type()
115
116 @property
117 def db_type(self):
118 return self.field.db_type
119
120 def __repr__(self):
121 return f"<{type(self).__name__}: {self.related_model._meta.package_label}.{self.related_model._meta.model_name}>"
122
123 @property
124 def identity(self):
125 return (
126 self.field,
127 self.model,
128 self.related_name,
129 self.related_query_name,
130 make_hashable(self.limit_choices_to),
131 self.parent_link,
132 self.on_delete,
133 self.symmetrical,
134 self.multiple,
135 )
136
137 def __eq__(self, other):
138 if not isinstance(other, self.__class__):
139 return NotImplemented
140 return self.identity == other.identity
141
142 def __hash__(self):
143 return hash(self.identity)
144
145 def __getstate__(self):
146 state = self.__dict__.copy()
147 # Delete the path_infos cached property because it can be recalculated
148 # at first invocation after deserialization. The attribute must be
149 # removed because subclasses like ManyToOneRel may have a PathInfo
150 # which contains an intermediate M2M table that's been dynamically
151 # created and doesn't exist in the .models module.
152 # This is a reverse relation, so there is no reverse_path_infos to
153 # delete.
154 state.pop("path_infos", None)
155 return state
156
157 def get_choices(
158 self,
159 include_blank=True,
160 blank_choice=BLANK_CHOICE_DASH,
161 limit_choices_to=None,
162 ordering=(),
163 ):
164 """
165 Return choices with a default blank choices included, for use
166 as <select> choices for this field.
167
168 Analog of plain.models.fields.Field.get_choices(), provided
169 initially for utilization by RelatedFieldListFilter.
170 """
171 limit_choices_to = limit_choices_to or self.limit_choices_to
172 qs = self.related_model._default_manager.complex_filter(limit_choices_to)
173 if ordering:
174 qs = qs.order_by(*ordering)
175 return (blank_choice if include_blank else []) + [(x.pk, str(x)) for x in qs]
176
177 def is_hidden(self):
178 """Should the related object be hidden?"""
179 return bool(self.related_name) and self.related_name[-1] == "+"
180
181 def get_joining_columns(self):
182 return self.field.get_reverse_joining_columns()
183
184 def get_extra_restriction(self, alias, related_alias):
185 return self.field.get_extra_restriction(related_alias, alias)
186
187 def set_field_name(self):
188 """
189 Set the related field's name, this is not available until later stages
190 of app loading, so set_field_name is called from
191 set_attributes_from_rel()
192 """
193 # By default foreign object doesn't relate to any remote field (for
194 # example custom multicolumn joins currently have no remote field).
195 self.field_name = None
196
197 def get_accessor_name(self, model=None):
198 # This method encapsulates the logic that decides what name to give an
199 # accessor descriptor that retrieves related many-to-one or
200 # many-to-many objects. It uses the lowercased object_name + "_set",
201 # but this can be overridden with the "related_name" option. Due to
202 # backwards compatibility ModelForms need to be able to provide an
203 # alternate model. See BaseInlineFormSet.get_default_prefix().
204 opts = model._meta if model else self.related_model._meta
205 model = model or self.related_model
206 if self.multiple:
207 # If this is a symmetrical m2m relation on self, there is no
208 # reverse accessor.
209 if self.symmetrical and model == self.model:
210 return None
211 if self.related_name:
212 return self.related_name
213 return opts.model_name + ("_set" if self.multiple else "")
214
215 def get_path_info(self, filtered_relation=None):
216 if filtered_relation:
217 return self.field.get_reverse_path_info(filtered_relation)
218 else:
219 return self.field.reverse_path_infos
220
221 @cached_property
222 def path_infos(self):
223 return self.get_path_info()
224
225 def get_cache_name(self):
226 """
227 Return the name of the cache key to use for storing an instance of the
228 forward model on the reverse model.
229 """
230 return self.get_accessor_name()
231
232
233class ManyToOneRel(ForeignObjectRel):
234 """
235 Used by the ForeignKey field to store information about the relation.
236
237 ``_meta.get_fields()`` returns this class to provide access to the field
238 flags for the reverse relation.
239
240 Note: Because we somewhat abuse the Rel objects by using them as reverse
241 fields we get the funny situation where
242 ``ManyToOneRel.many_to_one == False`` and
243 ``ManyToOneRel.one_to_many == True``. This is unfortunate but the actual
244 ManyToOneRel class is a private API and there is work underway to turn
245 reverse relations into actual fields.
246 """
247
248 def __init__(
249 self,
250 field,
251 to,
252 field_name,
253 related_name=None,
254 related_query_name=None,
255 limit_choices_to=None,
256 parent_link=False,
257 on_delete=None,
258 ):
259 super().__init__(
260 field,
261 to,
262 related_name=related_name,
263 related_query_name=related_query_name,
264 limit_choices_to=limit_choices_to,
265 parent_link=parent_link,
266 on_delete=on_delete,
267 )
268
269 self.field_name = field_name
270
271 def __getstate__(self):
272 state = super().__getstate__()
273 state.pop("related_model", None)
274 return state
275
276 @property
277 def identity(self):
278 return super().identity + (self.field_name,)
279
280 def get_related_field(self):
281 """
282 Return the Field in the 'to' object to which this relationship is tied.
283 """
284 field = self.model._meta.get_field(self.field_name)
285 if not field.concrete:
286 raise exceptions.FieldDoesNotExist(
287 f"No related field named '{self.field_name}'"
288 )
289 return field
290
291 def set_field_name(self):
292 self.field_name = self.field_name or self.model._meta.pk.name
293
294
295class ManyToManyRel(ForeignObjectRel):
296 """
297 Used by ManyToManyField to store information about the relation.
298
299 ``_meta.get_fields()`` returns this class to provide access to the field
300 flags for the reverse relation.
301 """
302
303 def __init__(
304 self,
305 field,
306 to,
307 *,
308 through,
309 through_fields=None,
310 related_name=None,
311 related_query_name=None,
312 limit_choices_to=None,
313 symmetrical=True,
314 ):
315 super().__init__(
316 field,
317 to,
318 related_name=related_name,
319 related_query_name=related_query_name,
320 limit_choices_to=limit_choices_to,
321 )
322
323 self.through = through
324 self.through_fields = through_fields
325
326 self.symmetrical = symmetrical
327 self.db_constraint = True
328
329 @property
330 def identity(self):
331 return super().identity + (
332 self.through,
333 make_hashable(self.through_fields),
334 self.db_constraint,
335 )
336
337 def get_related_field(self):
338 """
339 Return the field in the 'to' object to which this relationship is tied.
340 Provided for symmetry with ManyToOneRel.
341 """
342 opts = self.through._meta
343 if self.through_fields:
344 field = opts.get_field(self.through_fields[0])
345 else:
346 for field in opts.fields:
347 rel = getattr(field, "remote_field", None)
348 if rel and rel.model == self.model:
349 break
350 return field.foreign_related_fields[0]