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

103 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-10-30 12:03 +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 def get_absolute_url(self): 

51 ct = ContentType.objects.get_for_model(self) 

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

53 

54 def get_reset_url(self): 

55 ct = ContentType.objects.get_for_model(self) 

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

57 

58 def get_diff(self, other_version=None): 

59 if self.history_type == "-": 

60 return None 

61 

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

63 

64 old_version = other_version or self.prev_record or None 

65 old_version_dict = dict() 

66 if old_version: 

67 old_version_dict = modeldict( 

68 old_version.instance, exclude_noneditable=False 

69 ) 

70 

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

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

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

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

75 

76 newchanges = [] 

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

78 old_value = old_version_dict.pop(field, None) 

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

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

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

82 if value is not None: 

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

84 

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

86 

87 

88class VersionMixin(models.Model): 

89 history = APISHistoricalRecords( 

90 inherit=True, 

91 bases=[ 

92 APISHistoryTableBase, 

93 ], 

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

95 ) 

96 __history_date = None 

97 

98 @property 

99 def _history_date(self): 

100 return self.__history_date or timezone.now() 

101 

102 @_history_date.setter 

103 def _history_date(self, value): 

104 self.__history_date = value 

105 pass 

106 

107 class Meta: 

108 abstract = True 

109 

110 def get_history_url(self): 

111 ct = ContentType.objects.get_for_model(self) 

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

113 

114 def _get_historical_relations(self): 

115 ret = set() 

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

117 from apis_core.relations.utils import relation_content_types 

118 

119 ct = ContentType.objects.get_for_model(self) 

120 

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

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

123 rel_history_models = [ 

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

125 ] 

126 

127 for model in rel_history_models: 

128 for historical_relation in model.history.filter( 

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

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

131 ).order_by("history_id"): 

132 ret.add(historical_relation) 

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

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

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

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

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

138 for historical_relation in ret.copy(): 

139 if historical_relation.next_record: 

140 ret.add(historical_relation.next_record) 

141 return ret 

142 

143 def get_history_data(self): 

144 data = [] 

145 prev_entry = None 

146 queries = self._get_historical_relations() 

147 

148 for entry in queries: 

149 if prev_entry is not None: 

150 if ( 

151 entry.history_date == prev_entry.history_date 

152 and entry.history_user_id == prev_entry.history_user_id 

153 ): 

154 entry.history_type = prev_entry.history_type 

155 data[-1] = entry 

156 prev_entry = entry 

157 continue 

158 data.append(entry) 

159 prev_entry = entry 

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

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

162 return data 

163 

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

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