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

1import functools 

2import logging 

3 

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 

10 

11from apis_core.generic.abc import GenericModel 

12 

13logger = logging.getLogger(__name__) 

14 

15 

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 

30 

31 

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 ) 

51 

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) 

58 

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 ) 

65 

66 return new_class 

67 

68 

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() 

73 

74 

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") 

86 

87 objects = RelationManager() 

88 

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 ] 

108 

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) 

117 

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}" 

123 

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}" 

129 

130 def __str__(self): 

131 return self.subj_to_obj_text 

132 

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 

137 

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 

142 

143 @classmethod 

144 def name(cls) -> str: 

145 return cls._meta.verbose_name 

146 

147 @classmethod 

148 def reverse_name(cls) -> str: 

149 return cls._meta.verbose_name + " reverse" 

150 

151 @classmethod 

152 def name_and_reverse_name(cls) -> str: 

153 """ 

154 Return a string with both the name and the reverse name. 

155 

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()