diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index 88a01c15cf28489fde10c788895a35758bf857db..d196b044c610ae9ad94627e2f65d34c8d57da1a7 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -21,7 +21,7 @@ from django.views.generic.edit import ModelFormMixin 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 @@ -103,7 +103,7 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase): objects_all_sites = models.Manager() extra_permissions = [] - + def get_absolute_url(self) -> str: """Get the URL o a view representing this model instance.""" pass @@ -209,6 +209,66 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase): cls._safe_add(field, name) + @classmethod + def foreign_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() + cls.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[models.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 model 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"): + + def _virtual_related(self) -> models.QuerySet: + id_field_val = getattr(self, to_field) + return cls.objects.filter(**{id_field_name: id_field_val}) + + to.property_(_virtual_related, related_name) + @classmethod def syncable_fields(cls) -> List[models.Field]: """Collect all fields that can be synced on a model."""