Coverage for apis_core/history/models.py: 70%

106 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-30 06:11 +0000

1import inspect 

2from typing import Any 

3 

4import django 

5from django.conf import settings 

6from django.contrib.contenttypes.models import ContentType 

7from django.core.exceptions import AppRegistryNotReady 

8from django.db import models 

9from django.db.models import Q 

10from django.urls import reverse 

11from django.utils import timezone 

12from simple_history import utils 

13from simple_history.models import HistoricalRecords, ModelChange 

14 

15from apis_core.generic.abc import GenericModel 

16from apis_core.generic.templatetags.generic import modeldict 

17 

18 

19class APISHistoricalRecords(HistoricalRecords): 

20 def get_m2m_fields_from_model(self, model): 

21 # Change the original simple history function to also return m2m fields 

22 m2m_fields = [] 

23 try: 

24 for field in inspect.getmembers(model): 

25 if isinstance( 

26 field[1], 

27 django.db.models.fields.related_descriptors.ManyToManyDescriptor, 

28 ): 

29 m2m_fields.append(getattr(model, field[0]).field) 

30 except AppRegistryNotReady: 

31 pass 

32 return m2m_fields 

33 

34 def get_prev_record(self): 

35 """ 

36 Get the previous history record for the instance. `None` if first. 

37 """ 

38 history = utils.get_history_manager_from_history(self) 

39 return ( 

40 history.filter(history_date__lt=self.history_date) 

41 .order_by("history_date") 

42 .first() 

43 ) 

44 

45 

46class APISHistoryTableBase(GenericModel, models.Model): 

47 class Meta: 

48 abstract = True 

49 

50 class Config(GenericModel.Config): 

51 overview_section = None 

52 index_for_search = False 

53 

54 def get_absolute_url(self): 

55 ct = ContentType.objects.get_for_model(self) 

56 return reverse("apis_core:generic:detail", args=[ct, self.history_id]) 

57 

58 def get_reset_url(self): 

59 ct = ContentType.objects.get_for_model(self) 

60 return reverse("apis_core:history:reset", args=[ct, self.history_id]) 

61 

62 def get_diff(self, other_version=None): 

63 if self.history_type == "-": 

64 return None 

65 

66 new_version_dict = modeldict(self.instance, exclude_noneditable=False) 

67 

68 old_version = other_version or self.prev_record or None 

69 old_version_dict = dict() 

70 if old_version: 

71 old_version_dict = modeldict( 

72 old_version.instance, exclude_noneditable=False 

73 ) 

74 

75 # the `modeldict` method uses the field as a dict key but we are only interested 

76 # in the `.name` of the field, so lets replace the keys: 

77 new_version_dict = {key.name: value for key, value in new_version_dict.items()} 

78 old_version_dict = {key.name: value for key, value in old_version_dict.items()} 

79 

80 newchanges = [] 

81 for field, value in new_version_dict.items(): 

82 old_value = old_version_dict.pop(field, None) 

83 if (value or old_value) and value != old_value: 

84 newchanges.append(ModelChange(field, old_value, value)) 

85 for field, value in old_version_dict.items(): 

86 if value is not None: 

87 newchanges.append(ModelChange(field, value, None)) 

88 

89 return sorted(newchanges, key=lambda change: change.field) 

90 

91 

92class VersionMixin(models.Model): 

93 history = APISHistoricalRecords( 

94 inherit=True, 

95 bases=[ 

96 APISHistoryTableBase, 

97 ], 

98 custom_model_name=lambda x: f"Version{x}", 

99 ) 

100 __history_date = None 

101 

102 @property 

103 def _history_date(self): 

104 return self.__history_date or timezone.now() 

105 

106 @_history_date.setter 

107 def _history_date(self, value): 

108 self.__history_date = value 

109 pass 

110 

111 class Meta: 

112 abstract = True 

113 

114 def get_history_url(self): 

115 ct = ContentType.objects.get_for_model(self) 

116 return reverse("apis_core:history:history", args=[ct, self.id]) 

117 

118 def _get_historical_relations(self): 

119 ret = set() 

120 if "apis_core.relations" in settings.INSTALLED_APPS: 

121 from apis_core.relations.utils import relation_content_types 

122 

123 ct = ContentType.objects.get_for_model(self) 

124 

125 rel_content_types = relation_content_types(any_model=type(self)) 

126 rel_models = [ct.model_class() for ct in rel_content_types] 

127 rel_history_models = [ 

128 model for model in rel_models if issubclass(model, VersionMixin) 

129 ] 

130 

131 for model in rel_history_models: 

132 for historical_relation in model.history.filter( 

133 Q(subj_object_id=self.id, subj_content_type=ct) 

134 | Q(obj_object_id=self.id, obj_content_type=ct) 

135 ).order_by("history_id"): 

136 ret.add(historical_relation) 

137 # If there is a newer version of a historical relation, also 

138 # add it to the set. This can be the case when a relation subject 

139 # or object was changed and the relation does not point to this 

140 # object anymore. We still want to show the change to make it 

141 # clear that the relation does not point to the object anymore. 

142 for historical_relation in ret.copy(): 

143 if historical_relation.next_record: 

144 ret.add(historical_relation.next_record) 

145 return ret 

146 

147 def get_history_data(self): 

148 data = [] 

149 prev_entry = None 

150 queries = self._get_historical_relations() 

151 

152 for entry in queries: 

153 if prev_entry is not None: 

154 if ( 

155 entry.history_date == prev_entry.history_date 

156 and entry.history_user_id == prev_entry.history_user_id 

157 ): 

158 entry.history_type = prev_entry.history_type 

159 data[-1] = entry 

160 prev_entry = entry 

161 continue 

162 data.append(entry) 

163 prev_entry = entry 

164 data += [x for x in self.history.all()] 

165 data = sorted(data, key=lambda x: x.history_date, reverse=True) 

166 return data 

167 

168 def __init__(self, *args: Any, **kwargs: Any) -> None: 

169 super().__init__(*args, **kwargs)