Coverage for apis_core/history/models.py: 46%
102 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-06-25 10:00 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-06-25 10:00 +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(models.Model, GenericModel):
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 if isinstance(obj, list) and all(isinstance(el, dict) for el in obj):
69 ret = []
70 for el in obj:
71 key, val = el.values()
72 ret.append(str(val))
73 return ret
74 return obj
76 def get_diff(self, other_version=None):
77 if self.history_type == "-":
78 return None
79 version = other_version or self.prev_record
80 if version:
81 delta = self.diff_against(version, foreign_keys_are_objs=True)
82 else:
83 delta = self.diff_against(self.__class__(), foreign_keys_are_objs=True)
84 changes = list(
85 filter(
86 lambda x: (x.new != "" or x.old is not None)
87 and x.field != "id"
88 and not x.field.endswith("_ptr"),
89 delta.changes,
90 )
91 )
92 # The changes consist of `ModelChange` classes, which are frozen
93 # To flatten the many-to-many representation of those ModelChanges
94 # we use a separate list containing the changed values
95 newchanges = []
96 for change in changes:
97 change = dataclasses.replace(
98 change,
99 old=self._flatten_modelchange_many_to_many(change.old),
100 new=self._flatten_modelchange_many_to_many(change.new),
101 )
102 newchanges.append(change)
103 return sorted(newchanges, key=lambda change: change.field)
106class VersionMixin(models.Model):
107 history = APISHistoricalRecords(
108 inherit=True,
109 bases=[
110 APISHistoryTableBase,
111 ],
112 custom_model_name=lambda x: f"Version{x}",
113 )
114 __history_date = None
116 @property
117 def _history_date(self):
118 return self.__history_date or timezone.now()
120 @_history_date.setter
121 def _history_date(self, value):
122 self.__history_date = value
123 pass
125 class Meta:
126 abstract = True
128 def get_history_url(self):
129 ct = ContentType.objects.get_for_model(self)
130 return reverse("apis_core:history:history", args=[ct, self.id])
132 def _get_historical_relations(self):
133 ret = []
134 if "apis_core.relations" in settings.INSTALLED_APPS:
135 from apis_core.relations.utils import relation_content_types
137 ct = ContentType.objects.get_for_model(self)
139 rel_content_types = relation_content_types(any_model=type(self))
140 rel_models = [ct.model_class() for ct in rel_content_types]
141 rel_history_models = [
142 model for model in rel_models if issubclass(model, VersionMixin)
143 ]
145 for model in rel_history_models:
146 ret.append(
147 model.history.filter(
148 Q(subj_object_id=self.id, subj_content_type=ct)
149 | Q(obj_object_id=self.id, obj_content_type=ct)
150 ).order_by("history_id")
151 )
152 return ret
154 def get_history_data(self):
155 data = []
156 prev_entry = None
157 queries = self._get_historical_relations()
158 flatten_queries = [entry for query in queries for entry in query]
160 for entry in flatten_queries:
161 if prev_entry is not None:
162 if (
163 entry.history_date == prev_entry.history_date
164 and entry.history_user_id == prev_entry.history_user_id
165 ):
166 entry.history_type = prev_entry.history_type
167 data[-1] = entry
168 prev_entry = entry
169 continue
170 data.append(entry)
171 prev_entry = entry
172 data += [x for x in self.history.all()]
173 data = sorted(data, key=lambda x: x.history_date, reverse=True)
174 return data
176 def __init__(self, *args: Any, **kwargs: Any) -> None:
177 super().__init__(*args, **kwargs)