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
« prev ^ index » next coverage.py v7.5.3, created at 2025-10-30 12:03 +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 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 get_diff(self, other_version=None):
59 if self.history_type == "-":
60 return None
62 new_version_dict = modeldict(self.instance, exclude_noneditable=False)
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 )
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()}
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))
85 return sorted(newchanges, key=lambda change: change.field)
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
98 @property
99 def _history_date(self):
100 return self.__history_date or timezone.now()
102 @_history_date.setter
103 def _history_date(self, value):
104 self.__history_date = value
105 pass
107 class Meta:
108 abstract = True
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])
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
119 ct = ContentType.objects.get_for_model(self)
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 ]
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
143 def get_history_data(self):
144 data = []
145 prev_entry = None
146 queries = self._get_historical_relations()
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
164 def __init__(self, *args: Any, **kwargs: Any) -> None:
165 super().__init__(*args, **kwargs)