feat(core): history model for saving model history

!9 #5
This commit is contained in:
2024-05-23 12:40:14 +09:30
parent 41621c6a64
commit 9b2abecac3
11 changed files with 424 additions and 2 deletions

View File

@ -56,6 +56,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'core.middleware.get_request.RequestMiddleware',
]

View File

@ -20,6 +20,7 @@ from django.contrib.auth import views as auth_views
from django.urls import include, path
from .views import HomeView
from core.views import history
urlpatterns = [
path('', HomeView.as_view(), name='home'),
@ -29,7 +30,7 @@ urlpatterns = [
path("account/", include("django.contrib.auth.urls")),
path("organization/", include("access.urls")),
path("itam/", include("itam.urls")),
path("history/<str:model_name>/<int:model_pk>", HomeView.as_view(), name='_history'),
path("history/<str:model_name>/<int:model_pk>", history.View.as_view(), name='_history'),
]

View File

View File

@ -0,0 +1,21 @@
import threading
request_local = threading.local()
def get_request():
return getattr(request_local, 'request', None)
class RequestMiddleware():
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request_local.request = request
return self.get_response(request)
def process_exception(self, request, exception):
request_local.request = None
def process_template_response(self, request, response):
request_local.request = None
return response

View File

@ -0,0 +1,44 @@
# Generated by Django 5.0.6 on 2024-05-23 03:08
import access.fields
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('access', '0001_initial'),
('core', '0002_remove_notes_serial_number_alter_notes_device_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='notes',
name='note',
field=models.TextField(blank=True, default=None, null=True, verbose_name='Note'),
),
migrations.CreateModel(
name='History',
fields=[
('is_global', models.BooleanField(default=False)),
('id', models.AutoField(primary_key=True, serialize=False, unique=True)),
('created', access.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('before', models.TextField(blank=True, default=None, help_text='JSON Object before Change', null=True)),
('after', models.TextField(blank=True, default=None, help_text='JSON Object After Change', null=True)),
('action', models.IntegerField(choices=[('1', 'Create'), ('2', 'Update'), ('3', 'Delete')], default=None, null=True)),
('item_pk', models.IntegerField(default=None, null=True)),
('item_class', models.CharField(default=None, max_length=50, null=True)),
('item_parent_pk', models.IntegerField(default=None, null=True)),
('item_parent_class', models.CharField(default=None, max_length=50, null=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='access.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
]

View File

@ -0,0 +1,146 @@
import json
from django.db import models
from core.middleware.get_request import get_request
from core.models.history import History
class SaveHistory(models.Model):
class Meta:
abstract = True
@property
def fields(self):
return [ f.name for f in self._meta.fields + self._meta.many_to_many ]
def save(self, *args, **kwargs):
""" OverRides save for keeping model history.
Not a Full-Override as this is just to add to existing.
Before to fetch from DB to ensure the changed value is the actual changed value and the after
is the data that was saved to the DB.
"""
remove_keys = [
'_state',
'created',
'modified'
]
before = {}
try:
before = self.__class__.objects.get(pk=self.pk).__dict__.copy()
except Exception:
pass
clean = {}
for entry in before:
if type(before[entry]) == type(int()):
value = int(before[entry])
elif type(before[entry]) == type(bool()):
value = bool(before[entry])
else:
value = str(before[entry])
if entry not in remove_keys:
clean[entry] = value
before_json = json.dumps(clean)
# Process the save
super().save(*args, **kwargs)
after = self.__dict__.copy()
clean = {}
for entry in after:
if type(after[entry]) == type(int()):
value = int(after[entry])
elif type(after[entry]) == type(bool()):
value = bool(after[entry])
else:
value = str(after[entry])
if entry not in remove_keys and str(before) != '{}':
if after[entry] != before[entry]:
clean[entry] = value
elif entry not in remove_keys:
clean[entry] = value
after = json.dumps(clean)
item_parent_pk = None
item_parent_class = None
if self._meta.model_name == 'deviceoperatingsystem':
item_parent_pk = self.device.pk
item_parent_class = self.device._meta.model_name
if self._meta.model_name == 'devicesoftware':
item_parent_pk = self.device.pk
item_parent_class = self.device._meta.model_name
if self._meta.model_name == 'operatingsystemversion':
item_parent_pk = self.operating_system_id
item_parent_class = self.operating_system._meta.model_name
if self._meta.model_name == 'softwareversion':
item_parent_pk = self.software.pk
item_parent_class = self.software._meta.model_name
if not before:
action = History.Actions.ADD
elif before != after:
action = History.Actions.UPDATE
elif not after:
action = History.Actions.DELETE
if before != after and after != '{}':
entry = History.objects.create(
organization = self.organization,
is_global = False,
before = before_json,
after = after,
user = get_request().user,
action = action,
item_pk = self.pk,
item_class = self._meta.model_name,
item_parent_pk = item_parent_pk,
item_parent_class = item_parent_class,
)
entry.save()

View File

@ -0,0 +1,96 @@
from django.contrib.auth.models import User
from django.db import models
from access.fields import *
from access.models import TenancyObject
class NotesCommonFields(models.Model):
class Meta:
abstract = True
id = models.AutoField(
primary_key=True,
unique=True,
blank=False
)
created = AutoCreatedField()
class History(TenancyObject, NotesCommonFields):
class Meta:
ordering = [
'-created'
]
class Actions(models.TextChoices):
ADD = '1', 'Create'
UPDATE = '2', 'Update'
DELETE = '3', 'Delete'
before = models.TextField(
help_text = 'JSON Object before Change',
blank = True,
default = None,
null = True
)
after = models.TextField(
help_text = 'JSON Object After Change',
blank = True,
default = None,
null = True
)
action = models.IntegerField(
choices=Actions,
default=None,
null=True,
blank = False,
)
user = models.ForeignKey(
User,
on_delete=models.DO_NOTHING,
blank= False,
)
item_pk = models.IntegerField(
default=None,
null = True,
blank = False,
)
item_class = models.CharField(
blank = False,
default=None,
null = True,
max_length = 50,
unique = False,
)
item_parent_pk = models.IntegerField(
default=None,
null = True,
blank = False,
)
item_parent_class = models.CharField(
blank = False,
default=None,
null = True,
max_length = 50,
unique = False,
)

View File

@ -44,7 +44,7 @@ class Notes(NotesCommonFields):
note = models.TextField(
verbose_name = 'Note',
blank = False,
blank = True,
default = None,
null = True
)

View File

@ -0,0 +1,60 @@
{% extends 'base.html.j2' %}
{% load json %}
{% block body %}
<script>
$('.clicker').click(function(){
$(this).nextUntil('.clicker').slideToggle('normal');
});
</script>
<style>
.hidden {
/*display: none;*/
}
.down {
display: unset;
}
</style>
<table style="max-width: 100%;">
<thead>
<th style="width: 25%;">Created</th>
<th style="width: 25%;">Action</th>
<th style="width: 25%;">Item</th>
<th style="width: 25%;">User</th>
</thead>
{% for entry in history %}
<tr class="clicker">
<td>{{ entry.created }}</td>
<td>
{% if entry.action == 1 %}
Create
{% elif entry.action == 2 %}
Update
{% elif entry.action == 3 %}
Delete
{% else %}
fuck knows
{% endif %}
</td>
<td>
{{ entry.item_class}}
</td>
<td>{{ entry.user }}</td>
<tr class="hidden">
<th colspan="2">Before</th>
<th colspan="2">Changed</th>
</tr>
<tr class="hidden">
<td colspan="2"><pre style="text-align: left; max-width: 300px;">{{ entry.before | json_pretty }}</pre></td>
<td colspan="2"><pre style="text-align: left; max-width: 300px;">{{ entry.after | json_pretty }}</pre></td>
</tr>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@ -0,0 +1,13 @@
from django import template
from django.template.defaultfilters import stringfilter
import json
register = template.Library()
@register.filter()
@stringfilter
def json_pretty(value):
return json.dumps(json.loads(value), indent=4, sort_keys=True)

40
app/core/views/history.py Normal file
View File

@ -0,0 +1,40 @@
import markdown
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render
from django.views import generic
from access.mixin import OrganizationPermission
from core.models.history import History
from itam.models.device import Device, DeviceSoftware, DeviceOperatingSystem
from itam.models.software import Software
class View(OrganizationPermission, generic.View):
permission_required = [
'itam.view_softwareversion'
]
template_name = 'history.html.j2'
def get(self, request, model_name, model_pk):
if not request.user.is_authenticated and settings.LOGIN_REQUIRED:
return redirect(f"{settings.LOGIN_URL}?next={request.path}")
context = {}
context['history'] = History.objects.filter(
Q(item_pk = model_pk, item_class = model_name)
|
Q(item_parent_pk = model_pk, item_parent_class = model_name)
)
context['content_title'] = 'History'
return render(request, self.template_name, context)