Coverage for apis_core/history/models.py: 46%
102 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-09-03 06:15 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-09-03 06:15 +0000
1import dataclasses
2import inspect
3from typing import Any
5import django
6from django.conf import settings
7from django.contrib.contenttypes.models import ContentType
8from django.core.exceptions import AppRegistryNotReady
9from django.db import models
10from django.db.models import Q
11from django.urls import reverse
12from django.utils import timezone
13from simple_history import utils
14from simple_history.models import HistoricalRecords
16from apis_core.generic.abc import GenericModel
19class APISHistoricalRecords(HistoricalRecords, GenericModel):
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 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])
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])
58 def _flatten_modelchange_many_to_many(self, obj: Any) -> Any:
59 """
60 The `.diff_against` method of the `HistoricalChanges` model represent
61 changes in many-to-many fields as lists of dicts. Those dicts contain
62 a mapping of the model names to the model instances (i.e.
63 {'person': <Person1>, 'profession': <Profession2>}).
64 To make this a bit more readable, we flatten the dicts to one dict
65 containing a mapping between the model instance string representation
66 and the string representations of the connected model instances.
67 """
68 ret = []
69 for el in obj:
70 key, val = el.values()
71 ret.append(str(val))
72 return ret
74 def get_diff(self, other_version=None):
75 if self.history_type == "-":
76 return None
77 version = other_version or self.prev_record
78 if version:
79 delta = self.diff_against(version, foreign_keys_are_objs=True)
80 else:
81 delta = self.diff_against(self.__class__(), foreign_keys_are_objs=True)
82 changes = list(
83 filter(
84 lambda x: (x.new != "" or x.old is not None)
85 and x.field != "id"
86 and not x.field.endswith("_ptr"),
87 delta.changes,
88 )
89 )
90 # The changes consist of `ModelChange` classes, which are frozen
91 # To flatten the many-to-many representation of those ModelChanges
92 # we use a separate list containing the changed values
93 newchanges = []
94 m2m_fields = {field.name for field in self.instance_type._meta.many_to_many}
95 for change in changes:
96 # run flattening only on m2m fields
97 if change.field in m2m_fields:
98 change = dataclasses.replace(
99 change,
100 old=self._flatten_modelchange_many_to_many(change.old),
101 new=self._flatten_modelchange_many_to_many(change.new),
102 )
103 newchanges.append(change)
104 return sorted(newchanges, key=lambda change: change.field)
107class VersionMixin(models.Model):
108 history = APISHistoricalRecords(
109 inherit=True,
110 bases=[
111 APISHistoryTableBase,
112 ],
113 custom_model_name=lambda x: f"Version{x}",
114 )
115 __history_date = None
117 @property
118 def _history_date(self):
119 return self.__history_date or timezone.now()
121 @_history_date.setter
122 def _history_date(self, value):
123 self.__history_date = value
124 pass
126 class Meta:
127 abstract = True
129 def get_history_url(self):
130 ct = ContentType.objects.get_for_model(self)
131 return reverse("apis_core:history:history", args=[ct, self.id])
133 def _get_historical_relations(self):
134 ret = []
135 if "apis_core.relations" in settings.INSTALLED_APPS:
136 from apis_core.relations.utils import relation_content_types
138 ct = ContentType.objects.get_for_model(self)
140 rel_content_types = relation_content_types(any_model=type(self))
141 rel_models = [ct.model_class() for ct in rel_content_types]
142 rel_history_models = [
143 model for model in rel_models if issubclass(model, VersionMixin)
144 ]
146 for model in rel_history_models:
147 ret.append(
148 model.history.filter(
149 Q(subj_object_id=self.id, subj_content_type=ct)
150 | Q(obj_object_id=self.id, obj_content_type=ct)
151 ).order_by("history_id")
152 )
153 return ret
155 def get_history_data(self):
156 data = []
157 prev_entry = None
158 queries = self._get_historical_relations()
159 flatten_queries = [entry for query in queries for entry in query]
161 for entry in flatten_queries:
162 if prev_entry is not None:
163 if (
164 entry.history_date == prev_entry.history_date
165 and entry.history_user_id == prev_entry.history_user_id
166 ):
167 entry.history_type = prev_entry.history_type
168 data[-1] = entry
169 prev_entry = entry
170 continue
171 data.append(entry)
172 prev_entry = entry
173 data += [x for x in self.history.all()]
174 data = sorted(data, key=lambda x: x.history_date, reverse=True)
175 return data
177 def __init__(self, *args: Any, **kwargs: Any) -> None:
178 super().__init__(*args, **kwargs)