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
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-28 06:34 +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
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])
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])
61 def get_diff(self, other_version=None):
62 if self.history_type == "-":
63 return None
65 new_version_dict = modeldict(self.instance, exclude_noneditable=False)
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 )
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()}
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))
88 return sorted(newchanges, key=lambda change: change.field)
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
101 @property
102 def _history_date(self):
103 return self.__history_date or timezone.now()
105 @_history_date.setter
106 def _history_date(self, value):
107 self.__history_date = value
108 pass
110 class Meta:
111 abstract = True
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])
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
122 ct = ContentType.objects.get_for_model(self)
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 ]
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
146 def get_history_data(self):
147 data = []
148 prev_entry = None
149 queries = self._get_historical_relations()
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
167 def __init__(self, *args: Any, **kwargs: Any) -> None:
168 super().__init__(*args, **kwargs)