Plain is headed towards 1.0! Subscribe for development updates →

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