From 5d8f40390c2cdefda6931d1d48fe8d1972226cde Mon Sep 17 00:00:00 2001 From: Martin Rejman Date: Thu, 11 Apr 2019 23:57:56 +0200 Subject: [PATCH] Records_audit, EUweb, editace uzivatele --- src/main/settings_global.py | 1 + src/nalodeni/forms.py | 32 ++- src/nalodeni/models.py | 11 +- src/nalodeni/people.py | 73 +++++- src/nalodeni/static/img/flags/cs.png | Bin 0 -> 706 bytes src/nalodeni/static/img/flags/en.png | Bin 0 -> 1382 bytes src/nalodeni/static/img/flags/sk.png | Bin 0 -> 1270 bytes src/nalodeni/templates/people/list.html | 5 +- src/nalodeni/templates/person/detail.html | 59 +++++ src/nalodeni/templates/person/edit.html | 22 ++ src/nalodeni/templates/pirati_cz.html | 5 - .../templates/pirati_cz_euro2019.html | 9 +- src/nalodeni/urls.py | 3 + src/nalodeni/views.py | 62 ++--- src/records_audit/__init__.py | 0 src/records_audit/migrations/0001_initial.py | 62 +++++ .../migrations/0002_auto_20190411_2008.py | 17 ++ .../migrations/0003_auto_20190411_2010.py | 18 ++ src/records_audit/migrations/__init__.py | 0 src/records_audit/models.py | 69 ++++++ src/records_audit/utils.py | 233 ++++++++++++++++++ 21 files changed, 638 insertions(+), 43 deletions(-) create mode 100644 src/nalodeni/static/img/flags/cs.png create mode 100644 src/nalodeni/static/img/flags/en.png create mode 100644 src/nalodeni/static/img/flags/sk.png create mode 100644 src/nalodeni/templates/person/detail.html create mode 100644 src/nalodeni/templates/person/edit.html create mode 100644 src/records_audit/__init__.py create mode 100644 src/records_audit/migrations/0001_initial.py create mode 100644 src/records_audit/migrations/0002_auto_20190411_2008.py create mode 100644 src/records_audit/migrations/0003_auto_20190411_2010.py create mode 100644 src/records_audit/migrations/__init__.py create mode 100644 src/records_audit/models.py create mode 100644 src/records_audit/utils.py diff --git a/src/main/settings_global.py b/src/main/settings_global.py index 69ff0e9..6d08df6 100644 --- a/src/main/settings_global.py +++ b/src/main/settings_global.py @@ -52,6 +52,7 @@ INSTALLED_APPS = [ 'anymail', + 'records_audit', 'nalodeni', ] diff --git a/src/nalodeni/forms.py b/src/nalodeni/forms.py index 9c7858a..1bd5dd2 100644 --- a/src/nalodeni/forms.py +++ b/src/nalodeni/forms.py @@ -17,7 +17,8 @@ class AppUserForm(ModelForm): class Meta: model = models.AppUser - fields = ['first_name','last_name', 'city', 'postcode', 'district', 'kind', + fields = ['first_name','last_name', + 'city', 'postcode', 'district', 'kind', 'email', 'email_contact', 'email_contact_active', 'dc_stamp'] def clean_postcode(self): @@ -61,6 +62,35 @@ class AppUserSsoForm(ModelForm): fields = ['city', 'postcode', 'district', 'kind', 'email', 'email_contact', 'email_contact_active', 'dc_stamp'] + def clean_postcode(self): + data = self.cleaned_data['postcode'] + if data is not None: + if data < 10000 or data > 99999: + raise ValidationError(_('PSČ musí být číslo mezi 10000 a 99999.'), code='invalid') + return data + + def clean_first_name(self): + data = self.cleaned_data['first_name'] + if data is not None: + if len(data) > 30: + raise ValidationError(_('Jméno může mít maximálně 30 znaků.'), code='invalid') + return data + + def clean_last_name(self): + data = self.cleaned_data['last_name'] + if data is not None: + if len(data) > 30: + raise ValidationError(_('Příjmení může mít maximálně 30 znaků.'), code='invalid') + return data + + def clean_city(self): + data = self.cleaned_data['city'] + if data is not None: + if len(data) > 50: + raise ValidationError(_('Město může mít maximálně 50 znaků.'), code='invalid') + return data + + class AppRegEmailForm(ModelForm): class Meta: diff --git a/src/nalodeni/models.py b/src/nalodeni/models.py index cbf03fd..eb42409 100644 --- a/src/nalodeni/models.py +++ b/src/nalodeni/models.py @@ -16,8 +16,10 @@ from django.conf import settings as appSettings from keycloak_oidc import exceptions as sso_exc +from records_audit.utils import DataAudited -class AppUser(AbstractUser): + +class AppUser(AbstractUser, DataAudited): """ Prepare an empty User class just in case it will be needed later. """ @@ -241,12 +243,15 @@ class AppUser(AbstractUser): else: return self.email - + _audit_fields = ('postcode', 'district', 'kind', 'password') + _audit_fields_exclude = ('emailToken',) + _audit_fields_private = ('password',) class Meta: verbose_name = _('AppUser') verbose_name_plural = _('AppUsers') unique_together = (("email", ),("username",), ("emailToken",),) ordering = ('username',) + ## # Permission functions @@ -355,7 +360,7 @@ class UserTopic(Model): -class UserForm(Model): +class UserForm(Model, DataAudited): """ Dotaznik ohledne dovednosti a schopnosti uzivatele. """ diff --git a/src/nalodeni/people.py b/src/nalodeni/people.py index 0acf1d0..66dd495 100644 --- a/src/nalodeni/people.py +++ b/src/nalodeni/people.py @@ -6,7 +6,7 @@ from collections import OrderedDict import django from django.http import HttpResponse, HttpResponseRedirect from django.template import Template, RequestContext, loader -from django.shortcuts import render +from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required from django.urls import reverse @@ -195,3 +195,74 @@ def update(request): return HttpResponseRedirect('/people/list/') + +@login_required(login_url="/prihlaseni") +@transaction.atomic +@role_required(['sso_kodo']) +def person_detail(request, id): + obj = models.USER_MODEL.objects.get(pk=id) + + sp = request.session['site_perms'] + sp_sso_kodo = 'sso_kodo' in sp + sp_sso_admin = 'sso_admin' in sp + + # Check permissions to edit this object + if not sp_sso_admin and not obj.district in get_AppUser_districts(request): + messages.error(request, 'K tomuto záznamu nemáte přístup. ') + return redirect('nalodeni:people_list') + + template = 'person/detail.html' + context = { + 'obj' : obj, + } + + return render(request, template, context) + +@login_required(login_url="/prihlaseni") +@transaction.atomic +@role_required(['sso_kodo']) +def person_edit(request, id): + obj = models.USER_MODEL.objects.get(pk=id) + + sp = request.session['site_perms'] + sp_sso_kodo = 'sso_kodo' in sp + sp_sso_admin = 'sso_admin' in sp + + # Check permissions to edit this object + if not sp_sso_admin and not obj.district in get_AppUser_districts(request): + messages.error(request, 'K tomuto záznamu nemáte přístup. ') + return redirect('nalodeni:people_list') + + if obj.ssoUid: + _form = forms.AppUserSsoForm + else: + _form = forms.AppUserForm + + if request.method == "GET": + form = _form(instance=obj) + + elif request.method == "POST": + with obj.audit_context(request.user) as ac: + form = _form(request.POST, instance=obj) + if form.is_valid(): + form.save() + + messages.info(request, "Údaje byly uloženy.") + return redirect('nalodeni:person_detail', form.instance.id) + + else: + messages.error(request, "Opravte prosím chyby v zadání.") + raise ac.DataNotSavedException + else: + form = None + + template = 'person/edit.html' + context = { + 'obj' : obj, + 'form' : form, + 'AUTH_SERVER' : appSettings.AUTH_SERVER, + } + + return render(request, template, context) + + diff --git a/src/nalodeni/static/img/flags/cs.png b/src/nalodeni/static/img/flags/cs.png new file mode 100644 index 0000000000000000000000000000000000000000..872e7507a2f6e1d0349665af5a620c6cc3b0d0d4 GIT binary patch literal 706 zcmV;z0zLhSP)}0001EP)t-s|NsBj z6dDmle$)T}06%-(5fT78d2WcRkSa(=YL?~d@GDk{KRs^97$a;nTeBZAt0FfqKy-I7 zR8l!+mMBEM94ulsV1h1B*52n+bDNv2#E6}^vA)p6003huR(z7No+m*?vi*ht00J6G zL_t(&fz8=p@K#3uhnDQ%q6%webc9t%$>`*elF`}KYm{W?E|=`hTI7$0 z2l6mSE~;vTWMh|;tzWEJ7hMPs^P&r2WfLy*l})+&WR@$NcG)JIarGbnX31t~tIuA3!rIIYk)vrwWdr6wB|0Pd0S*lC=FJ`jKt*jDEmf>>EF1i0o;B@-*#B@`>uwJXI~87`@1WxB+cmF4R0 zL{+IS$z`RxWS5of+KR_YakbLRN^{9CE5)U;tOVC?rNxSIDJ?73rM0X$mujqV*RyJ@ zP*}6ISqm=9STnB15;f^+ZDUQj z?4IGAYcKnaHPvOkEPq2+ix8_ExgIc70~f@y{GGZmmgTQNj8z_89n7-)6^OCQg9|KH o>0R9abKSmJ$d<^TWy07*qoM6N<$f;o>+=Kufz literal 0 HcmV?d00001 diff --git a/src/nalodeni/static/img/flags/en.png b/src/nalodeni/static/img/flags/en.png new file mode 100644 index 0000000000000000000000000000000000000000..2c554434f70e0074980dee00b72ec17f69e952b3 GIT binary patch literal 1382 zcmV-s1)2JZP)WYOQHUuE~=s(wt9Z5nVh}QCNoM>kyKii<(!_= zIz)7tc#!}A1b<0HK~!jg?b_*b+At6RU>TE$!*|sXQXJBz31`~Ug!g~7VqMm0OJI`B zl>L=q2Kz&nHn6*3x(0Im94BedF^1#O;T;1Qhye$O*GA)^^F@;J&+5jv^N?I1i~HKS>%f)9e~D@z5mt^csN$_2RN1SW>||FOzbdL?N|uQ%oG!a}k)7yD<9n={ z%$W*q*u%@}N2s}cg>D;an;VQ`bK4JpUrideV-lWN|RTj%3y;kDX9z+S!j~k&gK!W z!x<#1fbA=!tI7ma>6XZrh{+63)4XQhE~*+#`b<^ms&O*xfnHCLL7p{kRoP=QJJ~gh zAE}CEhWv+7)x$19wu%~Lu_p6!G1Zo<3Y&_#Ofon(teNsOJzQzRR6JkVRkiDIkGO1= zeSMU(hO;ZZe*B76yIQr-RB`mFPJ|cU7|TWhBcCC!mtGxTxxCOLKczWU6ZPQW{id7e1RrHifxW_WNem z=$hU9CMtFYi|pA^WpfF#4LW!;%gG$?1Z7!TOUVtpyYqDQf*Ds=C znW2EH3cNg7RZT9OHLz^WWvH*pfW?Jhe+!u^dcIOsYGzPe*Ddj(+o3)os|u0~Q255{5Dx1#=_{?Ew)#Ya>Pb~Co)J?8 zZ`Dz5tLI3veccYi4ZWl4IjsIAsdB1@JEdWQ>?~O9JB(k>pJ?nQ!s)(s_syedYA<6g&pt7cZ2kTs!RXa`P-5-+nS>5X- zGFyMBQFSl#`=#!tZP*ttGehvw#bzJqfu3`RtHmc)323e%g7=F zHdW456S`KN^|#G3I=jL9+a}e9uhovII-Um*-9cEU3J2U1iYF~6$F os&q>pX%Lc@|A)={3bCI41E;yT@_?k)YybcN07*qoM6N<$f=}YTbpQYW literal 0 HcmV?d00001 diff --git a/src/nalodeni/static/img/flags/sk.png b/src/nalodeni/static/img/flags/sk.png new file mode 100644 index 0000000000000000000000000000000000000000..87cc69854e1ebf0f1360f6d1b1962bffc5642815 GIT binary patch literal 1270 zcmV}0002bP)t-s?i?lm z|Nje4qV^aUbU}jvN1u9+!0s3#>;M4&`1$TFF91xT{qOJY6CV52)$jEAHC?FT=I{Vg ztnB~~079MsKAi3+D#_5}?hF|KIhy&#$N9g&__nsv+3EF^m-e5a^L%{wuCIrj$M8;5 z@IOWMU0p&+mjE)EOh}ZHqsqK4SDZL)wk}y>M2hU30K+g}d`}b?(B)PC1LwAV2Na+;uj704bJ@?oGo0 z00ZJlL_t(&f$f=#ZktLFKp9-hVs`DoVtf695O5xCYKOXQZrmHUy}AGYOS|0!mA#_s~*Cu9>S|0!mA#_ zs~#1+v`Vz@H7ETnEdq+T102hKiN`MmD2zY6yD41?c|m$C$!K6xB4gcu`ygXG&IA^hD$L0{@3k2?1{ zZiLAT5PM}pQCDl#%SgQlO%wXsD-5Sz!;AL3^wf)Z_KF}5rLh3~4?KSp*8SM`g8kOzoweKg zQhh(1ad=xj_EknHWq#7}TlJEUeN{cQ&`kf54qdI)eVfk{{d6BX?bXRXB#$0oMXGqI z5-dl}X_d|sU;a3!o%Z7Aw1;VXUdki5w2Bu$?Os{!dFdCh%PXu0hJn|4uNgckEqW=( zfi)?l-u;s-KYi_Gqf|4L2Ft$8D=M%!k+Gi2KAZgcy7?|{5Q%hHUQMR4)Qcx_5PA__ z{fwzn@1q%CF#m}n{4ac8(HtI|Ok=5+JdIAay>GXsDSuwGZ)SSIyzi~Q%BFe!Va;cM zfSV?@%Dt5LFhjljNXNWpx5q2j>ZdsRv4<9L*Jv8cz2uuAtbQe{q~E^2l72BiY_y@( zWYt&k;tRI>S^UuHkEWNaf>&5!cZFMw^$g863AvJ&{v8T?rmhSdei`LTUh?P)hBt4o z$NvU=X>x0fsNxk)6~^=pF?~9LNt2UAjP$MwFaPmppA^%%Gk&RblUhiE`t%uK zK)Ti96AiKfmKTRt&ypVi9F(8_I{>XPXm?grWy#H5fB*mh literal 0 HcmV?d00001 diff --git a/src/nalodeni/templates/people/list.html b/src/nalodeni/templates/people/list.html index f764b16..9a2b7f4 100644 --- a/src/nalodeni/templates/people/list.html +++ b/src/nalodeni/templates/people/list.html @@ -61,7 +61,10 @@ $(document).ready(function(){ {{p.get_district_display}} {{p.get_kind_display}} {{p.interestedIn|default_if_none:'-'}} - + + detail, + upravit + {% endfor %} diff --git a/src/nalodeni/templates/person/detail.html b/src/nalodeni/templates/person/detail.html new file mode 100644 index 0000000..75c136e --- /dev/null +++ b/src/nalodeni/templates/person/detail.html @@ -0,0 +1,59 @@ +{% extends 'pirati_cz.html' %} + +{%block head%} + + +{%endblock%} + +{%block body%} +
+
+
+

Detail uživatele (zpět, + upravit)

+ + + + + + + {{obj.email_contact|default_if_none:'-'}}{% if not obj.email_contact_verified %} (neověřen){%endif%} + + + + + + + + + + +
Uživ. jméno (login){{obj.username}}
Jméno a příjmení{{obj.first_name|default_if_none:'-'}} {{obj.last_name|default_if_none:'-'}}
Město, PSČ, Kraj{{obj.city|default_if_none:'-'}}, {{obj.postcode|default_if_none:'-'}}, {{obj.get_district_display|default_if_none:'-'}}
 
Reg. e-mail{{obj.email}}
Kontaktní e-mail
 
Stav{{obj.get_status_display}}
Chci{{obj.get_kind_display}}
 
Zájmy + {% for i in obj.userform.topics.all %}{{i.name}}
{%endfor%} +
Dovednosti + {% for i in obj.userform.skills.all %}{{i.name}}
{%endfor%} +
Regiony + {% for i in obj.userform.regions.all %}{{i.name}}
{%endfor%} +
 
Datum registrace{{obj.createdStamp}}
Datum souhlasu os. údajů{{obj.dc_stamp|default_if_none:'-'}}
+
+
+
+ +
+
+
+
+   +
+
+
+
+ + +{%endblock%} diff --git a/src/nalodeni/templates/person/edit.html b/src/nalodeni/templates/person/edit.html new file mode 100644 index 0000000..555ac0c --- /dev/null +++ b/src/nalodeni/templates/person/edit.html @@ -0,0 +1,22 @@ +{% extends 'pirati_cz.html' %} + +{%block body%} +
+
+
+
+
+

Profil uživatele {{boj}}

+
+ {%csrf_token%} + {{form}} + +
+
+
+
+
+
+ +{%endblock%} + diff --git a/src/nalodeni/templates/pirati_cz.html b/src/nalodeni/templates/pirati_cz.html index cbd28a8..c12ebc2 100644 --- a/src/nalodeni/templates/pirati_cz.html +++ b/src/nalodeni/templates/pirati_cz.html @@ -349,11 +349,6 @@ -
  • - - - -
  • diff --git a/src/nalodeni/templates/pirati_cz_euro2019.html b/src/nalodeni/templates/pirati_cz_euro2019.html index 37964e2..3441252 100644 --- a/src/nalodeni/templates/pirati_cz_euro2019.html +++ b/src/nalodeni/templates/pirati_cz_euro2019.html @@ -10,7 +10,7 @@ - + @@ -167,6 +167,8 @@ .o-section { padding-top: 35px; } + .flag {border: 1px solid black; height: 0.5em; width: 0.8em; margin: 0.25em; vertical-align: baseline; } + {% block headstyle %} {% endblock %} @@ -189,14 +191,15 @@

    diff --git a/src/nalodeni/urls.py b/src/nalodeni/urls.py index 71490e6..2de5261 100644 --- a/src/nalodeni/urls.py +++ b/src/nalodeni/urls.py @@ -29,6 +29,9 @@ urlpatterns = [ url(r'^ja-pirat/email-vizitka/$', views.email_vizitka, name="email_vizitka"), + url(r'^person/(?P[0-9]+)/$', people.person_detail, name="person_detail"), + url(r'^person/(?P[0-9]+)/edit$', people.person_edit, name="person_edit"), + url(r'^people/list/$', people.confirmed, name="people_list"), url(r'^people/list/(?P[0-9]+)/$', people.confirmed, name="people_list"), url(r'^people/list-new/$', people.confirmed, name="people_list_new", kwargs={'newOnly':True}), diff --git a/src/nalodeni/views.py b/src/nalodeni/views.py index 2913a75..a408d54 100644 --- a/src/nalodeni/views.py +++ b/src/nalodeni/views.py @@ -555,34 +555,36 @@ def profil(request): elif request.method == "POST": form = _form(request.POST, instance=request.user) email_contact_orig = request.user.email_contact - if form.is_valid(): - email_contact_changed = email_contact_orig != form.instance.email_contact + with request.user.audit_context(request.user) as audit: + if form.is_valid(): + email_contact_changed = email_contact_orig != form.instance.email_contact - if email_contact_changed: - form.instance.email_contact_verified = False - form.save() + if email_contact_changed: + form.instance.email_contact_verified = False + form.save() - if (form.instance.email_contact != None - and not form.instance.email_contact_verified): - try: - nalodeni_auth.sendEmailContactVerificationToken(form.instance) + if (form.instance.email_contact != None + and not form.instance.email_contact_verified): + try: + nalodeni_auth.sendEmailContactVerificationToken(form.instance) - messages.info(request, - "Potvrzovací email pro nastavení kontaktní adresy byl odeslán.") - except ValidationError as e: - messages.error(request, "Kontaktní email není správně vyplněn.") - except Exception as e: - logger.error("Chyba pri odesilani email_contact potvrzeni: %s" % e) - messages.error(request, - "Potvrzovací email pro změnu kontaktní adresy " + - "se nepodařilo odeslat.") + messages.info(request, + "Potvrzovací email pro nastavení kontaktní adresy byl odeslán.") + except ValidationError as e: + messages.error(request, "Kontaktní email není správně vyplněn.") + except Exception as e: + logger.error("Chyba pri odesilani email_contact potvrzeni: %s" % e) + messages.error(request, + "Potvrzovací email pro změnu kontaktní adresy " + + "se nepodařilo odeslat.") - messages.info(request, "Údaje byly uloženy.") - return redirect('nalodeni:ja_pirat') + messages.info(request, "Údaje byly uloženy.") + return redirect('nalodeni:ja_pirat') - else: - messages.error(request, "Opravte prosím chyby v zadání.") + else: + messages.error(request, "Opravte prosím chyby v zadání.") + raise audit.DataNotSavedException else: form = None @@ -614,15 +616,17 @@ def dotaznik(request): form = _form(instance=request.user.userform) elif request.method == "POST": - form = _form(request.POST, instance=request.user.userform) - if form.is_valid(): - form.save() + with request.user.userform.audit_context(request.user) as audit: + form = _form(request.POST, instance=request.user.userform) + if form.is_valid(): + form.save() - messages.info(request, "Údaje byly uloženy.") - return redirect('nalodeni:ja_pirat') + messages.info(request, "Údaje byly uloženy.") + return redirect('nalodeni:ja_pirat') - else: - messages.error(request, "Opravte prosím chyby v zadání.") + else: + messages.error(request, "Opravte prosím chyby v zadání.") + raise audit.DataNotSavedException else: form = None diff --git a/src/records_audit/__init__.py b/src/records_audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/records_audit/migrations/0001_initial.py b/src/records_audit/migrations/0001_initial.py new file mode 100644 index 0000000..b240c79 --- /dev/null +++ b/src/records_audit/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 2.0.3 on 2019-04-11 18:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AuditField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, verbose_name='Field name')), + ], + ), + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ts', models.DateTimeField(editable=False, verbose_name='Timestamp')), + ('tnum', models.IntegerField(default='-1', editable=False, verbose_name='Transaction number')), + ('user', models.IntegerField(editable=False, null=True, verbose_name='User ID')), + ('rid', models.IntegerField(editable=False, verbose_name='Table record ID')), + ('val', models.CharField(max_length=500, verbose_name='Field value')), + ('fld', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to='records_audit.AuditField', verbose_name='Audited field')), + ], + options={ + 'ordering': ('ts', 'tnum'), + }, + ), + migrations.CreateModel( + name='AuditTable', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, verbose_name='Table name')), + ], + ), + migrations.AlterUniqueTogether( + name='audittable', + unique_together={('name',)}, + ), + migrations.AddField( + model_name='auditlog', + name='table', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to='records_audit.AuditTable', verbose_name='Audited table'), + ), + migrations.AddField( + model_name='auditfield', + name='table', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='records_audit.AuditTable', verbose_name='Audited table'), + ), + migrations.AlterUniqueTogether( + name='auditfield', + unique_together={('name',)}, + ), + ] diff --git a/src/records_audit/migrations/0002_auto_20190411_2008.py b/src/records_audit/migrations/0002_auto_20190411_2008.py new file mode 100644 index 0000000..a6071ec --- /dev/null +++ b/src/records_audit/migrations/0002_auto_20190411_2008.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.3 on 2019-04-11 20:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('records_audit', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='auditfield', + unique_together={('table', 'name')}, + ), + ] diff --git a/src/records_audit/migrations/0003_auto_20190411_2010.py b/src/records_audit/migrations/0003_auto_20190411_2010.py new file mode 100644 index 0000000..8b85763 --- /dev/null +++ b/src/records_audit/migrations/0003_auto_20190411_2010.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2019-04-11 20:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('records_audit', '0002_auto_20190411_2008'), + ] + + operations = [ + migrations.RenameField( + model_name='auditlog', + old_name='user', + new_name='user_id', + ), + ] diff --git a/src/records_audit/migrations/__init__.py b/src/records_audit/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/records_audit/models.py b/src/records_audit/models.py new file mode 100644 index 0000000..1c4e60b --- /dev/null +++ b/src/records_audit/models.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +import datetime +import django +from django.db import models + +from django.utils.translation import ugettext_lazy as _ + + +class AuditTable(models.Model): + """ Translation table for names of audited tables + """ + name = models.CharField(_('Table name'), max_length=150) + + class Meta: + unique_together = (('name',),) + + +class AuditField(models.Model): + """ Audited fields in the table. + """ + table = models.ForeignKey(AuditTable, verbose_name=_('Audited table'), on_delete=models.PROTECT) + name = models.CharField(_('Field name'), max_length=150) + + class Meta: + unique_together = (('table','name',),) + + +class AuditLog(models.Model): + """ Track changes in records. """ + + ts = models.DateTimeField(_('Timestamp'), + null=False, editable=False) + + tnum = models.IntegerField(_('Transaction number'), default = "-1", + null=False, editable=False) + """ This number is unique within one AuditContext run. """ + + user_id = models.IntegerField(verbose_name=_('User ID'), + null=True, editable=False) + """ The user who performed this action. None, if unknown. + + The ID stored is the user-id from the get_user_model() table. Due to + circular reference, when auditing get_user_model() table, this is just + plain integer, not ForeignKey. + """ + + table = models.ForeignKey(AuditTable, verbose_name=_('Audited table'), on_delete=models.PROTECT, + null=False, editable=False) + + fld = models.ForeignKey(AuditField, verbose_name=_('Audited field'), on_delete=models.PROTECT, + null=False, editable=False) + + rid = models.IntegerField(_('Table record ID'), + null=False, editable=False) + + val = models.CharField(_('Field value'), max_length=500) + """ The latest value is stored in the actual model table. This value + is the previous one. + + The store timestamp is the last Datetime when the value Val was valid. + """ + + class Meta: + ordering = ('ts', 'tnum',) + """ We order by transaction start timestamp, then by 'tnum', because + a nested transaction can be saved earlier than the parent object. + """ + + diff --git a/src/records_audit/utils.py b/src/records_audit/utils.py new file mode 100644 index 0000000..635ea58 --- /dev/null +++ b/src/records_audit/utils.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +import datetime +import django +from django.db import models as dj_models + +from django.utils.translation import ugettext_lazy as _ + +from . import models + +_table_cache = {} + +#models.AuditTable.objects.all().delete() + +def extract_object_data(obj): + """ + Extract data fields from a Model object. + """ + af_fields = [] # array of Field objects + af = getattr(obj, '_audit_fields', None) + afe = getattr(obj, '_audit_fields_exclude', None) + + if af: + for f in af: + if afe and f in afe: # respect exclude fields + continue + af_fields.append(obj._meta.get_field(f)) + else: + if afe: + for f in obj._meta.get_fields(): + if afe and f.name in afe: # respect exclude fields + continue + af_fields.append(f) + else: + af_fields = obj._meta.get_fields() + + # walk through data fields, extract values + data = {} + for df in obj._meta.get_fields(): + # skip if audit_fields is explicitly defined + if af and df.name not in af: + continue + + if df.concrete: + if type(df) == dj_models.ForeignKey: + data[df.name] = getattr(obj, df.name+"_id") + elif type(df) == dj_models.ManyToManyField: + data[df.name] = set(getattr(obj, df.name).all().values_list('pk', flat=True)) + else: + data[df.name] = getattr(obj, df.name) + return data + +class DataAudited: + """ + Ensure auditing of changes by storing updated fields in a changelog + with timestamp, change-transaction-id and the user who made the change. + """ + + class DataNotSavedException(Exception): + """ + Throw this exception when data was partially saved, + but the transaction will be rolled back. Then, we should + not save the diff, because it will actually not happen. + """ + pass + + def __init__(self): + self.__audit_table = None + """ Link to the object structure cache. """ + + self.__orig_data = None + """ Is None when not in DataAudited "with" block of code. """ + + self.__audit_user = None + """ The user actually doing the changes in DB. """ + + self.__save = self.save + """ Original save method. """ + + self.__audit_start_ts = None + """ Timestamp of AuditContext creation. """ + + # setup our save method + self.save = self.__audited_save__ + + # + # check and update the object cache + # + tbl_name = self._meta.db_table + if tbl_name in _table_cache: + self.__audit_table = _table_cache[tbl_name] + else: + # create the record + _table_cache[tbl_name] = { + 'db_table' : tbl_name, + 'id' : None, + 'fields' : {}, + } + ltc = _table_cache[tbl_name] # local table cache variable + + # load or create AuditTable + rslt = models.AuditTable.objects.filter(name=tbl_name) + if len(rslt) == 1: + tbl = rslt[0] + elif len(rslt) == 0: + print("Creating AuditTable", tbl_name ) + tbl = models.AuditTable() + tbl.name = tbl_name + tbl.save() + else: + 1/0 + ltc['id'] = tbl.id + + # update AuditFields + known_af = tbl.auditfield_set.all() + wanted_af = set(extract_object_data(self).keys()) + for fld_obj in known_af: + if fld_obj.name in wanted_af: + # create cache record + ltc['fields'][fld_obj.name] = { + 'id' : fld_obj.id, + 'name' : fld_obj.name, + } + # remove known fields + wanted_af.remove(fld_obj.name) + + # create remaining wanted_af (there can be some new) + for fld_name in wanted_af: + print("Creating AuditField", tbl_name, fld_name ) + fld = models.AuditField(table=tbl, name=fld_name) + fld.save() + # create cache record + ltc['fields'][fld_name] = { + 'id' : fld.id, + 'name' : fld_name, + } + + + def __audited_save__(self, *args, **kwargs): + """ We audit all save()s, not just the explicit. + + We save the original save() method and provide our own + variant to ensure auditing of save()-s even outside + of the 'with' blocks of code. + + This leaves only direct database updates unaudited. + """ + if self.__orig_data: + # probably already in DataAudited with block, just save + self.__save(*args, **kwargs) + else: + # do the audit + obj_orig = type(self).objects.get(pk=self.id) + self.__audit_start_ts = datetime.datetime.now() + self.__orig_data = extract_object_data(obj_orig) + self.__save(*args, **kwargs) + self.__exit__(None, None, None) + self.__orig_data = None + self.__audit_start_ts = None + + def audit_context(self, user=None): + """ Setup self as the context. + + We also record the user doing the changes. + Transaction start timestamp is recorded to differentiate + nested DataAudited blocks. + + The transaction number 'tnum' is growing, but later TNUM + can be saved earlier then the parent object. + + Thus, we use timestamp to mitigate for this - correct + Audit log ordering is (ts, tnum). + """ + self.__audit_user = user + self.__audit_start_ts = datetime.datetime.now() + return self + + def __enter__(self): + #print("__enter__") + self.__orig_data = extract_object_data(self) + + #for k in self.__orig_data: + # print(' %s -> %s' % (k, self.__orig_data[k]) ) + return self + + def __exit__(self, exc_type, exc_value, traceback): + #print("__exit__", exc_type, exc_value, traceback) + if exc_type: + if exc_type == self.DataNotSavedException: + return True + else: + return False + + data_diff = {} + data_new = extract_object_data(self) + af_private = getattr(self, '_audit_fields_private', None) + + # We assume no new fields appear after __enter__ and before __exit__, + # so we do not look for keys in data_new that are not present in data_orig. + for f in self.__orig_data: + if self.__orig_data[f] != data_new[f]: + if af_private and f in af_private: + self.__orig_data[f] = "< ... hidden ... >" + data_new[f] = "< ... hidden ... >" + data_diff[f] = ( self.__orig_data[f], data_new[f] ) + + self.__save_diff(data_diff) + + self.__orig_data = None + self.__audit_start_ts = None + + return True + + def __save_diff(self, data_diff): + """ Save changes recorded in data_diff to DB. """ + + tnum = 1 + + print("AuditLog", self.__audit_table['db_table'], 'id', self.id) + for k in data_diff: + print(' %s: \t %s -> %s' % (k, data_diff[k][0], data_diff[k][1]) ) + + al = models.AuditLog( + ts = self.__audit_start_ts, + tnum = tnum, + user_id = self.__audit_user.id if self.__audit_user else None, + table_id = self.__audit_table['id'], + fld_id = self.__audit_table['fields'][k]['id'], + rid = self.id, + ) + al.val = data_diff[k][0] + al.save() + +