"""DSGVO-Löschkonzept: Aufbewahrungsfristen / Auto-Purge. Zwei Kategorien: - Lohn-/zeitrelevante Daten (time_entries, hours_payouts): gesetzliche Aufbewahrungspflicht, Frist pro Firma konfigurierbar über company.settings["retention_lohn_years"] (Default RETENTION_LOHN_DEFAULT_YEARS). Nach Ablauf werden die Zeilen hart gelöscht (keine Anonymisierung nötig, da nach Fristablauf keine Aufbewahrungspflicht mehr besteht und die Daten für den ursprünglichen Zweck nicht mehr benötigt werden). - Rein technische Daten (audit_logs, sessions, password_resets): feste, nicht pro Firma konfigurierbare Fristen. AuditLog RETENTION_AUDITLOG_YEARS, Sessions/Password-Resets werden gelöscht sobald abgelaufen (keine Aufbewahrungspflicht für Alt-Sessions). Läuft wie die Reminder-Jobs (scheduler_service.py) mit eigener DB-Session und RLS-Bypass, da firmenübergreifend bzw. ohne Tenant-Kontext gearbeitet wird. """ from __future__ import annotations import logging from datetime import date, datetime, timedelta, timezone from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.company import Company from app.models.audit_log import AuditLog from app.models.hours_payout import HoursPayout from app.models.password_reset import PasswordReset from app.models.session import Session from app.models.time_entry import TimeEntry from app.models.user import User log = logging.getLogger(__name__) RETENTION_LOHN_DEFAULT_YEARS = 10 RETENTION_AUDITLOG_YEARS = 3 def _lohn_retention_years(company: Company) -> int: s = company.settings or {} years = s.get("retention_lohn_years") return int(years) if years else RETENTION_LOHN_DEFAULT_YEARS async def _active_companies(db: AsyncSession, company_id=None) -> list[Company]: q = select(Company).where(Company.is_active.is_(True)) if company_id: q = q.where(Company.id == company_id) return list((await db.scalars(q)).all()) async def purge_company_lohn_data(db: AsyncSession, company: Company) -> dict: """Löscht time_entries + hours_payouts der Firma, die älter als die konfigurierte Aufbewahrungsfrist sind.""" years = _lohn_retention_years(company) cutoff_date = date.today() - timedelta(days=years * 365) cutoff_dt = datetime.now(timezone.utc) - timedelta(days=years * 365) user_ids_q = select(User.id).where(User.company_id == company.id) te_result = await db.execute( delete(TimeEntry) .where(TimeEntry.user_id.in_(user_ids_q), TimeEntry.date < cutoff_date) ) hp_result = await db.execute( delete(HoursPayout) .where(HoursPayout.company_id == company.id, HoursPayout.created_at < cutoff_dt) ) return {"time_entries": te_result.rowcount or 0, "hours_payouts": hp_result.rowcount or 0} async def purge_audit_logs(db: AsyncSession) -> int: cutoff = datetime.now(timezone.utc) - timedelta(days=RETENTION_AUDITLOG_YEARS * 365) result = await db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff)) return result.rowcount or 0 async def purge_expired_sessions(db: AsyncSession) -> int: now = datetime.now(timezone.utc) result = await db.execute(delete(Session).where(Session.expires_at < now)) return result.rowcount or 0 async def purge_expired_password_resets(db: AsyncSession) -> int: now = datetime.now(timezone.utc) result = await db.execute( delete(PasswordReset).where( (PasswordReset.expires_at < now) | (PasswordReset.used_at.isnot(None)) ) ) return result.rowcount or 0 async def run_retention_purge(db: AsyncSession, company_id=None) -> dict: """Orchestriert den vollständigen Purge-Lauf. Liefert Zähler je Kategorie.""" totals = {"time_entries": 0, "hours_payouts": 0} for company in await _active_companies(db, company_id): counts = await purge_company_lohn_data(db, company) totals["time_entries"] += counts["time_entries"] totals["hours_payouts"] += counts["hours_payouts"] # Technische Tabellen sind firmenübergreifend – nur beim globalen Lauf mitziehen, # nicht wenn ein einzelner Company-Admin gezielt nur seine eigene Firma anstößt. if company_id is None: totals["audit_logs"] = await purge_audit_logs(db) totals["sessions"] = await purge_expired_sessions(db) totals["password_resets"] = await purge_expired_password_resets(db) log.info("Retention-Purge abgeschlossen: %s", totals) return totals