Coverage for apis_core/relations/models.py: 83%
89 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-10-10 13:36 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-10-10 13:36 +0000
1import functools
2import logging
4from django.contrib.contenttypes.fields import GenericForeignKey
5from django.contrib.contenttypes.models import ContentType
6from django.core.exceptions import ValidationError
7from django.db import models
8from django.db.models.base import ModelBase
9from model_utils.managers import InheritanceManager
11from apis_core.generic.abc import GenericModel
13logger = logging.getLogger(__name__)
16class RelationManager(InheritanceManager):
17 def create_between_instances(self, subj, obj, *args, **kwargs):
18 subj_object_id = subj.pk
19 subj_content_type = ContentType.objects.get_for_model(subj)
20 obj_object_id = obj.pk
21 obj_content_type = ContentType.objects.get_for_model(obj)
22 rel = self.create(
23 subj_object_id=subj_object_id,
24 subj_content_type=subj_content_type,
25 obj_object_id=obj_object_id,
26 obj_content_type=obj_content_type,
27 )
28 logger.debug("Created relation %s between %s and %s", rel.name(), subj, obj)
29 return rel
32# This ModelBase is simply there to check if the needed attributes
33# are set in the Relation child classes.
34class RelationModelBase(ModelBase):
35 def __new__(metacls, name, bases, attrs):
36 if name == "Relation":
37 return super().__new__(metacls, name, bases, attrs)
38 else:
39 new_class = super().__new__(metacls, name, bases, attrs)
40 if not (new_class._meta.abstract or new_class._meta.proxy):
41 if not hasattr(new_class, "subj_model"):
42 raise ValueError(
43 "%s inherits from Relation and must therefore specify subj_model"
44 % name
45 )
46 if not hasattr(new_class, "obj_model"):
47 raise ValueError(
48 "%s inherits from Relation and must therefore specify obj_model"
49 % name
50 )
52 # `subj_model` or `obj_model` being a list was supported in an earlier
53 # version of apis, but it is not anymore
54 if isinstance(getattr(new_class, "subj_model", None), list):
55 raise ValueError("%s.subj_model must not be a list" % name)
56 if isinstance(getattr(new_class, "obj_model", None), list):
57 raise ValueError("%s.obj_model mut not be a list" % name)
59 if not new_class._meta.ordering:
60 logger.warning(
61 f"{name} inherits from Relation but does not specify 'ordering' in its Meta class. "
62 "Empty ordering could result in inconsitent results with pagination. "
63 "Set a ordering or inherit the Meta class from Relation.",
64 )
66 return new_class
69@functools.cache
70def get_by_natural_key(natural_key: str):
71 app_label, name = natural_key.lower().split(".")
72 return ContentType.objects.get_by_natural_key(app_label, name).model_class()
75class Relation(GenericModel, models.Model, metaclass=RelationModelBase):
76 subj_content_type = models.ForeignKey(
77 ContentType, on_delete=models.CASCADE, related_name="relation_subj_set"
78 )
79 subj_object_id = models.PositiveIntegerField(null=True)
80 subj = GenericForeignKey("subj_content_type", "subj_object_id")
81 obj_content_type = models.ForeignKey(
82 ContentType, on_delete=models.CASCADE, related_name="relation_obj_set"
83 )
84 obj_object_id = models.PositiveIntegerField(null=True)
85 obj = GenericForeignKey("obj_content_type", "obj_object_id")
87 objects = RelationManager()
89 class Meta:
90 indexes = [
91 models.Index(
92 fields=["subj_content_type"], name="relations_r_subj_content_type"
93 ),
94 models.Index(fields=["subj_object_id"], name="relations_r_subj_object_id"),
95 models.Index(
96 fields=["obj_content_type"], name="relations_r_obj_content_type"
97 ),
98 models.Index(fields=["obj_object_id"], name="relations_r_obj_object_id"),
99 models.Index(
100 fields=["subj_content_type", "subj_object_id"],
101 name="relations_r_subj_c_t_o_i",
102 ),
103 models.Index(
104 fields=["obj_content_type", "obj_object_id"],
105 name="relations_r_obj_c_t_o_i",
106 ),
107 ]
109 def save(self, *args, **kwargs):
110 subj_model = getattr(self, "subj_model", None)
111 if subj_model and self.subj_content_type is subj_model:
112 raise ValidationError(f"{self.subj} is not of type {subj_model}")
113 obj_model = getattr(self, "obj_model", None)
114 if obj_model and self.obj_content_type is obj_model:
115 raise ValidationError(f"{self.obj} is not of type {obj_model}")
116 super().save(*args, **kwargs)
118 @property
119 def subj_to_obj_text(self) -> str:
120 if hasattr(self, "name"):
121 return f"{self.subj} {self.name()} {self.obj}"
122 return f"{self.subj} relation to {self.obj}"
124 @property
125 def obj_to_subj_text(self) -> str:
126 if hasattr(self, "reverse_name"):
127 return f"{self.obj} {self.reverse_name()} {self.subj}"
128 return f"{self.obj} relation to {self.subj}"
130 def __str__(self):
131 return self.subj_to_obj_text
133 @classmethod
134 def subj_model_type(cls):
135 model = cls.subj_model
136 return get_by_natural_key(model) if isinstance(model, str) else model
138 @classmethod
139 def obj_model_type(cls):
140 model = cls.obj_model
141 return get_by_natural_key(model) if isinstance(model, str) else model
143 @classmethod
144 def name(cls) -> str:
145 return cls._meta.verbose_name
147 @classmethod
148 def reverse_name(cls) -> str:
149 return cls._meta.verbose_name + " reverse"
151 @classmethod
152 def name_and_reverse_name(cls) -> str:
153 """
154 Return a string with both the name and the reverse name.
156 If they are identical, return only the name.
157 """
158 if cls.name() != cls.reverse_name():
159 return f"{cls.name()} - {cls.reverse_name()}"
160 return cls.name()