From 305a338b3bc8a27cf7448e126aab40457719bde1 Mon Sep 17 00:00:00 2001 From: Tudor Amariei Date: Thu, 20 Nov 2025 11:48:29 +0200 Subject: [PATCH] feat: integrate auditlog for tracking changes --- backend/donations/models/ngos.py | 5 +++ backend/frequent_questions/models.py | 5 +++ backend/partners/models.py | 6 +++ backend/pyproject.toml | 1 + backend/redirectioneaza/settings/base.py | 2 + .../redirectioneaza/settings/environment.py | 2 + backend/redirectioneaza/settings/logging.py | 5 +++ backend/redirectioneaza/settings/unfold.py | 11 +++++ backend/users/models.py | 13 ++++++ .../management/commands/cleanup_auditlog.py | 19 ++++++++ .../commands/schedule_auditlog_cleanup.py | 43 +++++++++++++++++++ backend/uv.lock | 15 +++++++ docker/s6-rc.d/init/init.sh | 4 ++ 13 files changed, 131 insertions(+) create mode 100644 backend/utils/management/commands/cleanup_auditlog.py create mode 100644 backend/utils/management/commands/schedule_auditlog_cleanup.py diff --git a/backend/donations/models/ngos.py b/backend/donations/models/ngos.py index 02f2500f3..ae648f70b 100644 --- a/backend/donations/models/ngos.py +++ b/backend/donations/models/ngos.py @@ -3,6 +3,7 @@ from functools import partial from typing import Any +from auditlog.registry import auditlog from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError @@ -592,3 +593,7 @@ def can_receive_redirections(self): def delete_prefilled_form(self): if self.prefilled_form: self.prefilled_form.delete() + + +auditlog.register(Ngo) +auditlog.register(Cause) diff --git a/backend/frequent_questions/models.py b/backend/frequent_questions/models.py index e108e3849..31c18d70b 100644 --- a/backend/frequent_questions/models.py +++ b/backend/frequent_questions/models.py @@ -1,3 +1,4 @@ +from auditlog.registry import auditlog from django.db import models from django.utils.translation import gettext_lazy as _ from tinymce.models import HTMLField @@ -80,3 +81,7 @@ def get_all(): ) return questions + + +auditlog.register(Section) +auditlog.register(Question) diff --git a/backend/partners/models.py b/backend/partners/models.py index a0a5de147..0757b8d58 100644 --- a/backend/partners/models.py +++ b/backend/partners/models.py @@ -1,3 +1,4 @@ +from auditlog.registry import auditlog from django.db import models from django.db.models import QuerySet from django.db.models.functions import Lower @@ -202,3 +203,8 @@ def save(self, *args, **kwargs): self.display_order = number_of_partner_causes + 1 super().save(*args, **kwargs) + + +auditlog.register(Partner) +auditlog.register(PartnerNgo) +auditlog.register(PartnerCause) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6aab37df5..c55b05d69 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "cryptography~=46.0.0", "django~=5.2.0", "django-allauth~=65.13.0", + "django-auditlog~=3.3.0", "django-environ~=0.12.0", "django-import-export~=4.3.0", "django-ipware~=7.0.0", diff --git a/backend/redirectioneaza/settings/base.py b/backend/redirectioneaza/settings/base.py index 7ec6a9913..2f97d61c3 100644 --- a/backend/redirectioneaza/settings/base.py +++ b/backend/redirectioneaza/settings/base.py @@ -81,6 +81,7 @@ "django.contrib.sessions", "django.contrib.staticfiles", # third party apps: + "auditlog", "django_q", "django_recaptcha", "django_vite", @@ -116,6 +117,7 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", "partners.middleware.PartnerDomainMiddleware", "allauth.account.middleware.AccountMiddleware", + "auditlog.middleware.AuditlogMiddleware", ] AUTHENTICATION_BACKENDS = [ diff --git a/backend/redirectioneaza/settings/environment.py b/backend/redirectioneaza/settings/environment.py index aa22078f7..5e36d899e 100644 --- a/backend/redirectioneaza/settings/environment.py +++ b/backend/redirectioneaza/settings/environment.py @@ -132,6 +132,8 @@ ENABLE_FULL_VALIDATION_CNP=(bool, True), # Feature flags ENABLE_MULTIPLE_FORMS=(bool, False), + # + AUDITLOG_EXPIRY_DAYS=(int, 1 * 365), # 1 year ) environ.Env.read_env(ENV_FILE_PATH) diff --git a/backend/redirectioneaza/settings/logging.py b/backend/redirectioneaza/settings/logging.py index 7756b25e8..345cc1bac 100644 --- a/backend/redirectioneaza/settings/logging.py +++ b/backend/redirectioneaza/settings/logging.py @@ -49,3 +49,8 @@ }, }, } + +# Auditlog configuration + +AUDITLOG_EXPIRY_DAYS = env.int("AUDITLOG_EXPIRY_DAYS") +AUDITLOG_INCLUDE_ALL_MODELS = False diff --git a/backend/redirectioneaza/settings/unfold.py b/backend/redirectioneaza/settings/unfold.py index 84ddfece3..94c594db8 100644 --- a/backend/redirectioneaza/settings/unfold.py +++ b/backend/redirectioneaza/settings/unfold.py @@ -107,6 +107,17 @@ }, ], }, + { + "title": _("Audit Logs"), + "items": [ + { + "title": _("Audit Logs"), + "icon": "history", + "link": reverse_lazy("admin:auditlog_logentry_changelist"), + "permission": lambda request: request.user.is_superuser, + }, + ], + }, { "title": _("Background Tasks"), "items": [ diff --git a/backend/users/models.py b/backend/users/models.py index 09934c8f8..71b848c43 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,6 +1,7 @@ import hmac import uuid +from auditlog.registry import auditlog from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, Group, UserManager from django.db import models @@ -177,3 +178,15 @@ class Meta: verbose_name = _("Group") verbose_name_plural = _("Groups") + + +auditlog.register( + User, + exclude_fields=[ + "password", + "last_login", + "old_password", + "validation_token", + "token_timestamp", + ], +) diff --git a/backend/utils/management/commands/cleanup_auditlog.py b/backend/utils/management/commands/cleanup_auditlog.py new file mode 100644 index 000000000..c52da28a0 --- /dev/null +++ b/backend/utils/management/commands/cleanup_auditlog.py @@ -0,0 +1,19 @@ +import logging +from datetime import timedelta + +from auditlog.models import LogEntry +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Deletes expired auditlog entries" + + def handle(self, *args, **options): + cutoff_date = timezone.now() - timedelta(days=settings.AUDITLOG_EXPIRY_DAYS) + total_deleted, _per_category = LogEntry.objects.filter(timestamp__lt=cutoff_date).delete() + + logger.info("Deleted %d expired auditlog entries", total_deleted) diff --git a/backend/utils/management/commands/schedule_auditlog_cleanup.py b/backend/utils/management/commands/schedule_auditlog_cleanup.py new file mode 100644 index 000000000..2b8c6d72d --- /dev/null +++ b/backend/utils/management/commands/schedule_auditlog_cleanup.py @@ -0,0 +1,43 @@ +import logging +from datetime import timedelta + +from django.core.management import BaseCommand +from django.db.models import QuerySet +from django.utils import timezone +from django_q.models import Schedule +from django_q.tasks import schedule + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Schedule an expired auditlog cleanup task to run once a day" + schedule_name = "CLEANUP_AUDITLOG" + + def handle(self, *args, **kwargs): + logger.info("Scheduling auditlog cleanup") + + self.remove_existing_schedules() + self.schedule_auditlog_cleanup() + + logger.info("Auditlog cleanup scheduled successfully") + + def remove_existing_schedules(self): + existing_schedules: QuerySet[Schedule] = Schedule.objects.filter(name=self.schedule_name) + + if not existing_schedules.exists(): + return + + logger.info(f"Removing {existing_schedules.count()} existing schedules") + + existing_schedules.delete() + + def schedule_auditlog_cleanup(self): + schedule( + "django.core.management.call_command", + "cleanup_auditlog", + name=self.schedule_name, + schedule_type=Schedule.DAILY, + repeats=-1, + next_run=timezone.now() + timedelta(minutes=7), + ) diff --git a/backend/uv.lock b/backend/uv.lock index e0a249369..67105a479 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -344,6 +344,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/17/f2fd703781aeeb6d314059408df77360f09625cc3ce85f264b104443108c/django_allauth-65.13.0-py3-none-any.whl", hash = "sha256:119c0cf1cc2e0d1a0fe2f13588f30951d64989256084de2d60f13ab9308f9fa0", size = 1787213, upload-time = "2025-10-31T10:20:00.587Z" }, ] +[[package]] +name = "django-auditlog" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/d8/ddd1c653ffb7ed1984596420982e32a0b163a0be316721a801a54dcbf016/django_auditlog-3.3.0.tar.gz", hash = "sha256:01331a0e7bb1a8ff7573311b486c88f3d0c431c388f5a1e4a9b6b26911dd79b8", size = 85941, upload-time = "2025-10-02T17:16:27.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/bc/6e1b503d1755ab09cff6480cb088def073f1303165ab59b1a09247a2e756/django_auditlog-3.3.0-py3-none-any.whl", hash = "sha256:ab0f0f556a7107ac01c8fa87137bdfbb2b6f0debf70f7753169d9a40673d2636", size = 39676, upload-time = "2025-10-02T17:15:42.922Z" }, +] + [[package]] name = "django-environ" version = "0.12.0" @@ -1038,6 +1051,7 @@ dependencies = [ { name = "cryptography" }, { name = "django" }, { name = "django-allauth" }, + { name = "django-auditlog" }, { name = "django-environ" }, { name = "django-import-export" }, { name = "django-ipware" }, @@ -1092,6 +1106,7 @@ requires-dist = [ { name = "cryptography", specifier = "~=46.0.0" }, { name = "django", specifier = "~=5.2.0" }, { name = "django-allauth", specifier = "~=65.13.0" }, + { name = "django-auditlog", specifier = "~=3.3.0" }, { name = "django-environ", specifier = "~=0.12.0" }, { name = "django-import-export", specifier = "~=4.3.0" }, { name = "django-ipware", specifier = "~=7.0.0" }, diff --git a/docker/s6-rc.d/init/init.sh b/docker/s6-rc.d/init/init.sh index af7ee863d..4b45554b8 100755 --- a/docker/s6-rc.d/init/init.sh +++ b/docker/s6-rc.d/init/init.sh @@ -84,3 +84,7 @@ python3 manage.py schedule_stats_generator_ngos_yearly "ngos_with_ngohub_per_yea # Start the session clean-up schedule echo "Starting the session clean-up schedule that runs once a day" python3 manage.py schedule_session_cleanup + +# Start the expired auditlog clean-up schedule +echo "Starting the expired auditlog clean-up scheduler" +python3 manage.py schedule_auditlog_cleanup