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

105 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-28 06:34 +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 

53 def get_absolute_url(self): 

54 ct = ContentType.objects.get_for_model(self) 

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

56 

57 def get_reset_url(self): 

58 ct = ContentType.objects.get_for_model(self) 

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

60 

61 def get_diff(self, other_version=None): 

62 if self.history_type == "-": 

63 return None 

64 

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

66 

67 old_version = other_version or self.prev_record or None 

68 old_version_dict = dict() 

69 if old_version: 

70 old_version_dict = modeldict( 

71 old_version.instance, exclude_noneditable=False 

72 ) 

73 

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

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

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

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

78 

79 newchanges = [] 

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

81 old_value = old_version_dict.pop(field, None) 

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

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

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

85 if value is not None: 

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

87 

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

89 

90 

91class VersionMixin(models.Model): 

92 history = APISHistoricalRecords( 

93 inherit=True, 

94 bases=[ 

95 APISHistoryTableBase, 

96 ], 

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

98 ) 

99 __history_date = None 

100 

101 @property 

102 def _history_date(self): 

103 return self.__history_date or timezone.now() 

104 

105 @_history_date.setter 

106 def _history_date(self, value): 

107 self.__history_date = value 

108 pass 

109 

110 class Meta: 

111 abstract = True 

112 

113 def get_history_url(self): 

114 ct = ContentType.objects.get_for_model(self) 

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

116 

117 def _get_historical_relations(self): 

118 ret = set() 

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

120 from apis_core.relations.utils import relation_content_types 

121 

122 ct = ContentType.objects.get_for_model(self) 

123 

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

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

126 rel_history_models = [ 

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

128 ] 

129 

130 for model in rel_history_models: 

131 for historical_relation in model.history.filter( 

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

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

134 ).order_by("history_id"): 

135 ret.add(historical_relation) 

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

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

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

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

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

141 for historical_relation in ret.copy(): 

142 if historical_relation.next_record: 

143 ret.add(historical_relation.next_record) 

144 return ret 

145 

146 def get_history_data(self): 

147 data = [] 

148 prev_entry = None 

149 queries = self._get_historical_relations() 

150 

151 for entry in queries: 

152 if prev_entry is not None: 

153 if ( 

154 entry.history_date == prev_entry.history_date 

155 and entry.history_user_id == prev_entry.history_user_id 

156 ): 

157 entry.history_type = prev_entry.history_type 

158 data[-1] = entry 

159 prev_entry = entry 

160 continue 

161 data.append(entry) 

162 prev_entry = entry 

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

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

165 return data 

166 

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

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