From 7ae31f8a610e9a8b6b74550303ea540962428509 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Mon, 21 Sep 2020 17:34:27 +0200 Subject: [PATCH] Log DB modifications --- apps/api/urls.py | 5 + apps/logs/__init__.py | 4 + apps/logs/api/__init__.py | 0 apps/logs/api/serializers.py | 19 ++++ apps/logs/api/urls.py | 11 +++ apps/logs/api/views.py | 28 ++++++ apps/logs/apps.py | 18 ++++ apps/logs/migrations/0001_initial.py | 37 ++++++++ apps/logs/migrations/__init__.py | 0 apps/logs/models.py | 88 +++++++++++++++++ apps/logs/signals.py | 136 +++++++++++++++++++++++++++ corres2math/settings.py | 1 + 12 files changed, 347 insertions(+) create mode 100644 apps/logs/__init__.py create mode 100644 apps/logs/api/__init__.py create mode 100644 apps/logs/api/serializers.py create mode 100644 apps/logs/api/urls.py create mode 100644 apps/logs/api/views.py create mode 100644 apps/logs/apps.py create mode 100644 apps/logs/migrations/0001_initial.py create mode 100644 apps/logs/migrations/__init__.py create mode 100644 apps/logs/models.py create mode 100644 apps/logs/signals.py diff --git a/apps/api/urls.py b/apps/api/urls.py index c2bbfbf..9bf03c8 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.conf.urls import url, include from rest_framework import routers @@ -8,6 +9,10 @@ from .viewsets import UserViewSet router = routers.DefaultRouter() router.register('user', UserViewSet) +if "logs" in settings.INSTALLED_APPS: + from logs.api.views import ChangelogViewSet + router.register('logs', ChangelogViewSet) + app_name = 'api' # Wire up our API using automatic URL routing. diff --git a/apps/logs/__init__.py b/apps/logs/__init__.py new file mode 100644 index 0000000..58ee5b0 --- /dev/null +++ b/apps/logs/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +default_app_config = 'logs.apps.LogsConfig' diff --git a/apps/logs/api/__init__.py b/apps/logs/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/logs/api/serializers.py b/apps/logs/api/serializers.py new file mode 100644 index 0000000..c76e3a5 --- /dev/null +++ b/apps/logs/api/serializers.py @@ -0,0 +1,19 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework import serializers + +from ..models import Changelog + + +class ChangelogSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Changelog types. + The djangorestframework plugin will analyse the model `Changelog` and parse all fields in the API. + """ + + class Meta: + model = Changelog + fields = '__all__' + # noinspection PyProtectedMember + read_only_fields = [f.name for f in model._meta.get_fields()] # Changelogs are read-only protected diff --git a/apps/logs/api/urls.py b/apps/logs/api/urls.py new file mode 100644 index 0000000..9a0ceaa --- /dev/null +++ b/apps/logs/api/urls.py @@ -0,0 +1,11 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import ChangelogViewSet + + +def register_logs_urls(router, path): + """ + Configure router for Activity REST API. + """ + router.register(path, ChangelogViewSet) diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py new file mode 100644 index 0000000..5ac2a07 --- /dev/null +++ b/apps/logs/api/views.py @@ -0,0 +1,28 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import OrderingFilter +from rest_framework.viewsets import ModelViewSet + +from .serializers import ChangelogSerializer +from ..models import Changelog + + +class ChangelogViewSet(ModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, + then render it on /api/logs/ + """ + + def check_permissions(self, request): + # Only superusers can get access to logs + return self.request.user and self.request.user.is_superuser + + queryset = Changelog.objects.all() + serializer_class = ChangelogSerializer + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ] + ordering_fields = ['timestamp', 'id', ] + ordering = ['-id', ] diff --git a/apps/logs/apps.py b/apps/logs/apps.py new file mode 100644 index 0000000..239f86c --- /dev/null +++ b/apps/logs/apps.py @@ -0,0 +1,18 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig +from django.db.models.signals import pre_save, post_save, post_delete +from django.utils.translation import gettext_lazy as _ + + +class LogsConfig(AppConfig): + name = 'logs' + verbose_name = _('Logs') + + def ready(self): + # noinspection PyUnresolvedReferences + from . import signals + pre_save.connect(signals.pre_save_object) + post_save.connect(signals.save_object) + post_delete.connect(signals.delete_object) diff --git a/apps/logs/migrations/0001_initial.py b/apps/logs/migrations/0001_initial.py new file mode 100644 index 0000000..82431ad --- /dev/null +++ b/apps/logs/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.1 on 2020-09-21 15:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Changelog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP Address')), + ('instance_pk', models.CharField(max_length=255, verbose_name='identifier')), + ('previous', models.TextField(blank=True, default='', verbose_name='previous data')), + ('data', models.TextField(blank=True, default='', verbose_name='new data')), + ('action', models.CharField(choices=[('create', 'create'), ('edit', 'edit'), ('delete', 'delete')], default='edit', max_length=16, verbose_name='action')), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now, verbose_name='timestamp')), + ('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype', verbose_name='model')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'changelog', + 'verbose_name_plural': 'changelogs', + }, + ), + ] diff --git a/apps/logs/migrations/__init__.py b/apps/logs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/logs/models.py b/apps/logs/models.py new file mode 100644 index 0000000..0077af7 --- /dev/null +++ b/apps/logs/models.py @@ -0,0 +1,88 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class Changelog(models.Model): + """ + Store each modification in the database (except sessions and logging), + including creating, editing and deleting models. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + verbose_name=_('user'), + ) + + ip = models.GenericIPAddressField( + null=True, + blank=True, + verbose_name=_("IP Address") + ) + + model = models.ForeignKey( + ContentType, + on_delete=models.PROTECT, + null=False, + blank=False, + verbose_name=_('model'), + ) + + instance_pk = models.CharField( + max_length=255, + null=False, + blank=False, + verbose_name=_('identifier'), + ) + + previous = models.TextField( + blank=True, + default="", + verbose_name=_('previous data'), + ) + + data = models.TextField( + blank=True, + default="", + verbose_name=_('new data'), + ) + + action = models.CharField( # create, edit or delete + max_length=16, + null=False, + blank=False, + choices=[ + ('create', _('create')), + ('edit', _('edit')), + ('delete', _('delete')), + ], + default='edit', + verbose_name=_('action'), + ) + + timestamp = models.DateTimeField( + null=False, + blank=False, + default=timezone.now, + name='timestamp', + verbose_name=_('timestamp'), + ) + + def delete(self, using=None, keep_parents=False): + raise ValidationError(_("Logs cannot be destroyed.")) + + class Meta: + verbose_name = _("changelog") + verbose_name_plural = _("changelogs") + + def __str__(self): + return _("Changelog of type \"{action}\" for model {model} at {timestamp}").format( + action=self.get_action_display(), model=str(self.model), timestamp=str(self.timestamp)) diff --git a/apps/logs/signals.py b/apps/logs/signals.py new file mode 100644 index 0000000..a61b20c --- /dev/null +++ b/apps/logs/signals.py @@ -0,0 +1,136 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from rest_framework.renderers import JSONRenderer +from rest_framework.serializers import ModelSerializer +from corres2math.middlewares import get_current_authenticated_user, get_current_ip + +from .models import Changelog + +import getpass + + +# Ces modèles ne nécessitent pas de logs +EXCLUDED = [ + 'admin.logentry', + 'authtoken.token', + 'contenttypes.contenttype', + 'logs.changelog', # Never remove this line + 'mailer.dontsendentry', + 'mailer.message', + 'mailer.messagelog', + 'migrations.migration', + 'sessions.session', +] + + +def pre_save_object(sender, instance, **kwargs): + """ + Before a model get saved, we get the previous instance that is currently in the database + """ + qs = sender.objects.filter(pk=instance.pk).all() + if qs.exists(): + instance._previous = qs.get() + else: + instance._previous = None + + +def save_object(sender, instance, **kwargs): + """ + Each time a model is saved, an entry in the table `Changelog` is added in the database + in order to store each modification made + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"): + return + + # noinspection PyProtectedMember + previous = instance._previous + + # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP + user, ip = get_current_authenticated_user(), get_current_ip() + + if user is None: + # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` + # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée + ip = "127.0.0.1" + username = getpass.getuser() + if User.objects.filter(username=username).exists(): + user = User.objects.get(username=username) + + # noinspection PyProtectedMember + if user is not None and instance._meta.label_lower == "auth.user" and previous: + # On n'enregistre pas les connexions + if instance.last_login != previous.last_login: + return + + changed_fields = '__all__' + if previous: + # On ne garde que les champs modifiés + changed_fields = [] + for field in instance._meta.fields: + if field.name.endswith("_ptr"): + # A field ending with _ptr is a OneToOneRel with a subclass, e.g. NoteClub.note_ptr -> Note + continue + if getattr(instance, field.name) != getattr(previous, field.name): + changed_fields.append(field.name) + + if len(changed_fields) == 0: + # Pas de log s'il n'y a pas de modification + return + + # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles avec uniquement les champs modifiés + class CustomSerializer(ModelSerializer): + class Meta: + model = instance.__class__ + fields = changed_fields + + previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else "" + instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8") + + Changelog.objects.create(user=user, + ip=ip, + model=ContentType.objects.get_for_model(instance), + instance_pk=instance.pk, + previous=previous_json, + data=instance_json, + action=("edit" if previous else "create") + ).save() + + +def delete_object(sender, instance, **kwargs): + """ + Each time a model is deleted, an entry in the table `Changelog` is added in the database + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"): + return + + # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP + user, ip = get_current_authenticated_user(), get_current_ip() + + if user is None: + # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` + # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée + ip = "127.0.0.1" + username = getpass.getuser() + if User.objects.filter(username=username).exists(): + user = User.objects.get(username=username) + + # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles + class CustomSerializer(ModelSerializer): + class Meta: + model = instance.__class__ + fields = '__all__' + + instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8") + + Changelog.objects.create(user=user, + ip=ip, + model=ContentType.objects.get_for_model(instance), + instance_pk=instance.pk, + previous=instance_json, + data="", + action="delete" + ).save() diff --git a/corres2math/settings.py b/corres2math/settings.py index 4d116ab..26d4e0d 100644 --- a/corres2math/settings.py +++ b/corres2math/settings.py @@ -54,6 +54,7 @@ INSTALLED_APPS = [ 'crispy_forms', 'django_extensions', 'django_tables2', + 'logs', 'mailer', 'polymorphic', 'rest_framework',