diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 00000000..1b17aec6 --- /dev/null +++ b/apps/api/__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 = 'api.apps.APIConfig' diff --git a/apps/api/apps.py b/apps/api/apps.py new file mode 100644 index 00000000..11d78652 --- /dev/null +++ b/apps/api/apps.py @@ -0,0 +1,10 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class APIConfig(AppConfig): + name = 'api' + verbose_name = _('API') diff --git a/apps/logs/__init__.py b/apps/logs/__init__.py new file mode 100644 index 00000000..58ee5b08 --- /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/apps.py b/apps/logs/apps.py new file mode 100644 index 00000000..f48820c7 --- /dev/null +++ b/apps/logs/apps.py @@ -0,0 +1,14 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class LogsConfig(AppConfig): + name = 'logs' + verbose_name = _('Logs') + + def ready(self): + # noinspection PyUnresolvedReferences + import logs.signals diff --git a/apps/logs/migrations/__init__.py b/apps/logs/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/logs/models.py b/apps/logs/models.py new file mode 100644 index 00000000..337315bb --- /dev/null +++ b/apps/logs/models.py @@ -0,0 +1,71 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models + + +class Changelog(models.Model): + """ + Store each modification on 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( + null=True, + verbose_name=_('previous data'), + ) + + data = models.TextField( + null=True, + verbose_name=_('new data'), + ) + + action = models.CharField( # create, edit or delete + max_length=16, + null=False, + blank=False, + verbose_name=_('action'), + ) + + timestamp = models.DateTimeField( + null=False, + blank=False, + auto_now_add=True, + name='timestamp', + verbose_name=_('timestamp'), + ) + + def delete(self, using=None, keep_parents=False): + raise ValidationError(_("Logs cannot be destroyed.")) diff --git a/apps/logs/signals.py b/apps/logs/signals.py new file mode 100644 index 00000000..55e0f041 --- /dev/null +++ b/apps/logs/signals.py @@ -0,0 +1,119 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import inspect + +from django.contrib.contenttypes.models import ContentType +from django.core import serializers +from django.db.models.signals import pre_save, post_save, post_delete +from django.dispatch import receiver +from .models import Changelog + + +def get_request_in_signal(sender): + req = None + for entry in reversed(inspect.stack()): + try: + req = entry[0].f_locals['request'] + # Check if there is a user + # noinspection PyStatementEffect + req.user + break + except: + pass + + if not req: + print("WARNING: Attempt to save " + str(sender) + " with no user") + + return req + + +def get_user_and_ip(sender): + req = get_request_in_signal(sender) + try: + user = req.user + if 'HTTP_X_FORWARDED_FOR' in req.META: + ip = req.META.get('HTTP_X_FORWARDED_FOR') + else: + ip = req.META.get('REMOTE_ADDR') + except: + user = None + ip = None + return user, ip + + +EXCLUDED = [ + 'admin.logentry', + 'authtoken.token', + 'cas_server.user', + 'cas_server.userattributes', + 'contenttypes.contenttype', + 'logs.changelog', + 'migrations.migration', + 'note.noteuser', + 'note.noteclub', + 'note.notespecial', + 'sessions.session', + 'reversion.revision', + 'reversion.version', + ] + + +@receiver(pre_save) +def pre_save_object(sender, instance, **kwargs): + qs = sender.objects.filter(pk=instance.pk).all() + if qs.exists(): + instance._previous = qs.get() + else: + instance._previous = None + + +@receiver(post_save) +def save_object(sender, instance, **kwargs): + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + previous = instance._previous + + user, ip = get_user_and_ip(sender) + + if user is not None and instance._meta.label_lower == "auth.user" and previous: + # Don't save last login modifications + if instance.last_login != previous.last_login: + return + + previous_json = serializers.serialize('json', [previous, ])[1:-1] if previous else None + instance_json = serializers.serialize('json', [instance, ])[1:-1] + + if previous_json == instance_json: + # No modification + return + + 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() + + +@receiver(post_delete) +def delete_object(sender, instance, **kwargs): + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + user, ip = get_user_and_ip(sender) + + instance_json = serializers.serialize('json', [instance, ])[1:-1] + Changelog.objects.create(user=user, + ip=ip, + model=ContentType.objects.get_for_model(instance), + instance_pk=instance.pk, + previous=instance_json, + data=None, + action="delete" + ).save() diff --git a/apps/logs/urls.py b/apps/logs/urls.py new file mode 100644 index 00000000..6d76674c --- /dev/null +++ b/apps/logs/urls.py @@ -0,0 +1,8 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +app_name = 'logs' + +# TODO User interface +urlpatterns = [ +] diff --git a/apps/member/apps.py b/apps/member/apps.py index 2d7f4ab7..83dfbc40 100644 --- a/apps/member/apps.py +++ b/apps/member/apps.py @@ -2,9 +2,22 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.apps import AppConfig +from django.conf import settings +from django.db.models.signals import post_save from django.utils.translation import gettext_lazy as _ +from .signals import save_user_profile + class MemberConfig(AppConfig): name = 'member' verbose_name = _('member') + + def ready(self): + """ + Define app internal signals to interact with other apps + """ + post_save.connect( + save_user_profile, + sender=settings.AUTH_USER_MODEL, + ) diff --git a/apps/member/signals.py b/apps/member/signals.py index 4e945ad5..b17b3ae8 100644 --- a/apps/member/signals.py +++ b/apps/member/signals.py @@ -1,2 +1,15 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later + +def save_user_profile(instance, created, raw, **_kwargs): + """ + Hook to create and save a profile when an user is updated if it is not registered with the signup form + """ + if raw: + # When provisionning data, do not try to autocreate + return + + if created: + from .models import Profile + Profile.objects.get_or_create(user=instance) + instance.profile.save() diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 9e7b56d5..386db34c 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -82,6 +82,46 @@ msgstr "" msgid "guests" msgstr "" +#: apps/api/apps.py:10 +msgid "API" +msgstr "" + +#: apps/logs/apps.py:10 +msgid "Logs" +msgstr "" + +#: apps/logs/models.py:20 apps/note/models/notes.py:105 +msgid "user" +msgstr "" + +#: apps/logs/models.py:27 +msgid "model" +msgstr "" + +#: apps/logs/models.py:34 +msgid "identifier" +msgstr "" + +#: apps/logs/models.py:39 +msgid "previous data" +msgstr "" + +#: apps/logs/models.py:44 +msgid "new data" +msgstr "" + +#: apps/logs/models.py:51 +msgid "action" +msgstr "" + +#: apps/logs/models.py:59 +msgid "timestamp" +msgstr "" + +#: apps/logs/models.py:63 +msgid "Logs cannot be destroyed." +msgstr "" + #: apps/member/apps.py:10 msgid "member" msgstr "" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 4bd4f5f2..e7341740 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -77,6 +77,50 @@ msgstr "invité" msgid "guests" msgstr "invités" +#: apps/api/apps.py:10 +msgid "API" +msgstr "" + +#: apps/logs/apps.py:10 +msgid "Logs" +msgstr "" + +#: apps/logs/models.py:20 apps/note/models/notes.py:105 +msgid "user" +msgstr "utilisateur" + +#: apps/logs/models.py:27 +msgid "model" +msgstr "Modèle" + +#: apps/logs/models.py:34 +msgid "identifier" +msgstr "Identifiant" + +#: apps/logs/models.py:39 +msgid "previous data" +msgstr "Données précédentes" + +#: apps/logs/models.py:44 +#, fuzzy +#| msgid "end date" +msgid "new data" +msgstr "Nouvelles données" + +#: apps/logs/models.py:51 +#, fuzzy +#| msgid "section" +msgid "action" +msgstr "Action" + +#: apps/logs/models.py:59 +msgid "timestamp" +msgstr "Date" + +#: apps/logs/models.py:63 +msgid "Logs cannot be destroyed." +msgstr "Les logs ne peuvent pas être détruits." + #: apps/member/apps.py:10 msgid "member" msgstr "adhérent" diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 5a3c3f6b..1810989d 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -64,6 +64,7 @@ INSTALLED_APPS = [ 'member', 'note', 'api', + 'logs', ] LOGIN_REDIRECT_URL = '/note/transfer/' diff --git a/note_kfet/urls.py b/note_kfet/urls.py index a5502412..a261a9eb 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -32,6 +32,8 @@ urlpatterns = [ # Include Django REST API path('api/', include('api.urls')), + + path('logs/', include('logs.urls')), ] urlpatterns += static(settings.MEDIA_URL,document_root=settings.MEDIA_ROOT)