Neuer retention_service.py: Lohn-/zeitrelevante Daten (time_entries, hours_payouts) werden nach konfigurierbarer Frist gelöscht (company.settings.retention_lohn_years, Default 10 Jahre). Technische Tabellen mit fester Frist: audit_logs (3 Jahre), abgelaufene sessions/password_resets (sofort). Täglicher Scheduler-Job (03:00 Uhr, Redis-Tageslock analog Reminder-Jobs) plus manuelle Trigger: POST /companies/me/run-retention-purge (COMPANY_ADMIN/HR, nur eigene Firma) und POST /admin/run-retention-purge (SUPER_ADMIN, global inkl. technischer Tabellen). Letzter offener Punkt aus dem DSGVO-Löschkonzept (Art. 15/17 bereits erledigt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
112 lines
4.4 KiB
Python
112 lines
4.4 KiB
Python
"""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
|