diff --git a/acore/settings/base.py b/acore/settings/base.py index 292ae2c..cbb7875 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -185,6 +185,10 @@ MEDIA_ROOT = BASE_DIR / 'media' LOGIN_URL = '/accounts/login/' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# Срок хранения для авто-очистки (manage.py cleanup): журналы синхронизации, +# инвайты паролей старше N дней удаляются. Просроченные сессии — всегда. +CLEANUP_LOG_RETENTION_DAYS = env.int('CLEANUP_LOG_RETENTION_DAYS', default=90) + # Required for OSM tile usage policy: send origin as Referer on cross-origin requests. SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' diff --git a/core/management/commands/cleanup.py b/core/management/commands/cleanup.py new file mode 100644 index 0000000..a0b6ec2 --- /dev/null +++ b/core/management/commands/cleanup.py @@ -0,0 +1,74 @@ +""" +python manage.py cleanup [--days 90] [--dry-run] + +Авто-очистка того, что накапливается бесконечно: + • журналы синхронизации (AD / Carbonio / Nextcloud / Mattermost) старше N дней; + • использованные/просроченные инвайты на установку пароля старше N дней; + • просроченные сессии Django. + +Бизнес-данные (посещения, задачи, аптеки, отчёты и т.п.) НЕ трогаются. +Запускать по cron (см. docs). Срок хранения — --days или settings.CLEANUP_LOG_RETENTION_DAYS. +""" +from datetime import timedelta + +from django.apps import apps +from django.conf import settings +from django.contrib.sessions.models import Session +from django.core.management.base import BaseCommand +from django.utils import timezone + +# (app_label, model_name, date_field) — модели ищем через apps.get_model, +# чтобы команда не падала, если приложение/модель отсутствуют. +TARGETS = [ + ('ad_connector', 'ADSyncLog', 'created_at'), + ('mail_connector', 'CarbonioSyncLog', 'created_at'), + ('nextcloud_connector', 'NextcloudSyncLog', 'created_at'), + ('mattermost_connector', 'MattermostSyncLog', 'created_at'), + ('employees', 'PasswordSetupInvite', 'created_at'), +] + + +class Command(BaseCommand): + help = 'Авто-очистка устаревших журналов синхронизации, инвайтов и просроченных сессий.' + + def add_arguments(self, parser): + parser.add_argument( + '--days', type=int, + default=getattr(settings, 'CLEANUP_LOG_RETENTION_DAYS', 90), + help='Срок хранения в днях (default: settings.CLEANUP_LOG_RETENTION_DAYS или 90)', + ) + parser.add_argument( + '--dry-run', action='store_true', + help='Только показать, что будет удалено, ничего не удаляя', + ) + + def handle(self, *args, **options): + days = options['days'] + dry = options['dry_run'] + cutoff = timezone.now() - timedelta(days=days) + verb = 'будет удалено' if dry else 'удалено' + total = 0 + + for app_label, model_name, date_field in TARGETS: + try: + model = apps.get_model(app_label, model_name) + except LookupError: + continue + qs = model.objects.filter(**{f'{date_field}__lt': cutoff}) + n = qs.count() + if n and not dry: + qs.delete() + total += n + self.stdout.write(f'{app_label}.{model_name}: {verb} {n} (старше {days} дн.)') + + # Просроченные сессии — всегда (не зависит от --days). + sess = Session.objects.filter(expire_date__lt=timezone.now()) + ns = sess.count() + if ns and not dry: + sess.delete() + self.stdout.write(f'sessions: {verb} {ns} (просроченные)') + + suffix = ' — dry-run, ничего не удалено' if dry else '' + self.stdout.write(self.style.SUCCESS( + f'Итого: журналов/инвайтов {total}, сессий {ns}{suffix}' + )) diff --git a/core/tests_cleanup.py b/core/tests_cleanup.py new file mode 100644 index 0000000..7ba6a42 --- /dev/null +++ b/core/tests_cleanup.py @@ -0,0 +1,40 @@ +"""Тесты команды авто-очистки (manage.py cleanup).""" +from datetime import timedelta +from io import StringIO + +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from apps.employees.models import Employee +from apps.mattermost_connector.models import MattermostSyncLog + + +class CleanupCommandTests(TestCase): + def setUp(self): + self.emp = Employee.objects.create( + username='u1', email='u1@example.com', + hire_date=timezone.now().date(), + ) + + def _log(self, days_ago): + log = MattermostSyncLog.objects.create( + employee=self.emp, action='add_team', status='success', message='t', + ) + # created_at = auto_now_add; сдвигаем принудительно + MattermostSyncLog.objects.filter(pk=log.pk).update( + created_at=timezone.now() - timedelta(days=days_ago) + ) + return log + + def test_deletes_old_keeps_recent(self): + old = self._log(120) + recent = self._log(10) + call_command('cleanup', '--days', '90', stdout=StringIO()) + self.assertFalse(MattermostSyncLog.objects.filter(pk=old.pk).exists()) + self.assertTrue(MattermostSyncLog.objects.filter(pk=recent.pk).exists()) + + def test_dry_run_deletes_nothing(self): + self._log(200) + call_command('cleanup', '--days', '90', '--dry-run', stdout=StringIO()) + self.assertEqual(MattermostSyncLog.objects.count(), 1)