"""B23: ретенция журналов — реальное удаление, а не фильтр при чтении. До сих пор «ретенция 90 дней» из security_settings реализовывалась как фильтр в GET /admin/users/audit — записи копились в БД вечно. Этот модуль удаляет просроченное по-настоящему (DELETE), охватывая: audit_events (+ audit_changes каскадно) — по audit_retention_days auth_events — по audit_retention_days Запуск: POST /api/v1/admin/maintenance/purge-logs (право manage_security) или вручную: docker exec vector-backend python -m backend.purge_logs (режим разового запуска — для cron внутри контейнера) """ from __future__ import annotations import os from datetime import datetime, timedelta, timezone from sqlalchemy import text from sqlalchemy.orm import Session from backend.logging_setup import get_logger from backend.models import AuthEvent, SecuritySetting log = get_logger('retention') def _retention_days(db: Session, default: int = 90) -> int: row = db.query(SecuritySetting).filter( SecuritySetting.key == 'audit_retention_days').first() try: return int(row.value) if row else default except (TypeError, ValueError): return default def purge_logs(db: Session, retention_days: int | None = None) -> dict[str, int]: """Удалить записи журналов старше retention_days. Возвращает счётчики. audit_changes удаляются каскадом по FK (ondelete=CASCADE); для SQLite-тестов (без каскада на уровне БД) удаляем явно. """ if retention_days is None: retention_days = _retention_days(db) cutoff = datetime.now(timezone.utc) - timedelta(days=max(0, retention_days)) # SQLite отдаёт naive datetimes (DateTime(timezone=True) не respected) — # сравнение aware-cutoff с naive-колонкой падает TypeError. Убираем tzinfo: cutoff = cutoff.replace(tzinfo=None) audit_deleted = 0 try: from backend.models import AuditChange, AuditEvent change_ids = (db.query(AuditChange.id) .join(AuditEvent, AuditChange.event_id == AuditEvent.id) .filter(AuditEvent.created_at < cutoff).all()) change_ids = [row[0] for row in change_ids] if change_ids: (db.query(AuditChange) .filter(AuditChange.id.in_(change_ids)) .delete(synchronize_session=False)) # changes считаются вместе с событием audit_deleted += (db.query(AuditEvent) .filter(AuditEvent.created_at < cutoff) .delete(synchronize_session=False)) except Exception as e: db.rollback() log.error('purge audit_events не удался: %s', e) raise auth_deleted = 0 try: auth_deleted += (db.query(AuthEvent) .filter(AuthEvent.created_at < cutoff) .delete(synchronize_session=False)) except Exception as e: log.error('purge auth_events не удался: %s', e) raise db.commit() if audit_deleted or auth_deleted: log.info('retention purge: %d аудит-записей, %d auth-записей удалено (cutoff=%s, %d дн.)', audit_deleted, auth_deleted, cutoff.date(), retention_days) return {'audit_events': audit_deleted, 'auth_events': auth_deleted} def _cli() -> None: """Разовый прогон: python -m backend.purge_logs (для cron).""" from backend.database import SessionLocal with SessionLocal() as db: result = purge_logs(db) print(f'purge: {result}') raise SystemExit(0) if __name__ == '__main__': _cli()