diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index f6a8c18c483d0833d296abd6310cc2103331ba18..1fb6cbffa9a98a4eb96cacaa6777c6fa3ac7bacb 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -15,7 +15,7 @@ from django.utils.functional import lazy import reversion from easyaudit.models import CRUDEvent from guardian.admin import GuardedModelAdmin -from jsonstore.fields import JSONField, JSONFieldMixin +from jsonstore.fields import IntegerField, JSONField, JSONFieldMixin from material.base import Layout, LayoutNode from rules.contrib.admin import ObjectPermissionsModelAdmin @@ -197,6 +197,71 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase): cls._safe_add(field, name) + @classmethod + def foreignn_key( + cls, + field_name: str, + to: models.Model, + to_field: str = "pk", + to_field_type: JSONFieldMixin = IntegerField, + related_name: Optional[str] = None, + ) -> None: + """Add a virtual ForeignKey. + + This works by storing the primary key (or any field passed in the to_field argument) + and adding a property that queries the desired model. + + If the foreign model also is an ExtensibleModel, a reverse mapping is also added under + the related_name passed as argument, or this model's default related name. + """ + + id_field_name = f"{field_name}_id" + if related_name is None: + related_name = cls.Meta.default_related_name + + # Add field to hold key to foreign model + id_field = to_field_type() + self.field(**{id_field_name: id_field}) + + @property + def _virtual_fk(self) -> Optional[models.Model]: + id_field_val = getattr(self, id_field_name) + if id_field_val: + try: + return to.objects.get(**{to_field: id_field_val}) + except to.DoesNotExist: + # We found a stale foreign key + setattr(self, id_field_name, None) + self.save() + return None + else: + return None + + @_virtual_fk.setter + def _virtual_fk(self, value: Optional[Model] = None) -> None: + if value is None: + id_field_val = None + else: + id_field_val = getattr(value, to_field) + setattr(self, id_field_name, id_field_val) + + # Add property to wrap get/set on foreign mdoel instance + cls._safe_add(_virtual_fk, field_name) + + # Add related property on foreign model instance if it provides such an interface + if hasattr(to, "_safe_add"): + + @property + def _virtual_related(self) -> Optional[models.Model]: + id_field_val = getattr(self, to_field) + try: + return cls.objects.get(**{id_field_name: id_field_val}) + except cls.DoesNotExist: + # Nothing references us + return None + + to._safe_add(_virtual_related, related_name) + @classmethod def syncable_fields(cls) -> List[models.Field]: """Collect all fields that can be synced on a model."""