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
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-30 06:11 +0000
1import inspect
2from typing import Any
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
15from apis_core.generic.abc import GenericModel
16from apis_core.generic.templatetags.generic import modeldict
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
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 )
46class APISHistoryTableBase(GenericModel, models.Model):
47 class Meta:
48 abstract = True
50 class Config(GenericModel.Config):
51 overview_section = None
52 index_for_search = False
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])
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])
62 def get_diff(self, other_version=None):
63 if self.history_type == "-":
64 return None
66 new_version_dict = modeldict(self.instance, exclude_noneditable=False)
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 )
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()}
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))
89 return sorted(newchanges, key=lambda change: change.field)
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
102 @property
103 def _history_date(self):
104 return self.__history_date or timezone.now()
106 @_history_date.setter
107 def _history_date(self, value):
108 self.__history_date = value
109 pass
111 class Meta:
112 abstract = True
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])
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
123 ct = ContentType.objects.get_for_model(self)
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 ]
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
147 def get_history_data(self):
148 data = []
149 prev_entry = None
150 queries = self._get_historical_relations()
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
168 def __init__(self, *args: Any, **kwargs: Any) -> None:
169 super().__init__(*args, **kwargs)