- Brand-enforced export pipeline (PPTX/PDF with auto brand fonts/colors/logo) - Client library dashboard with two-level navigation (client grid → detail tabs) - Data retention service with ARQ cron jobs (daily cleanup + weekly purge) - Brand-adaptive UI theme via CSS custom properties (dynamic per client) - Analytics dashboard with overview, usage, quality, and performance metrics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
113 lines
4.3 KiB
Python
113 lines
4.3 KiB
Python
"""Data retention service: auto-purge expired presentations per client policy."""
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models.sql.client import ClientModel
|
|
from models.sql.presentation import PresentationModel
|
|
from services import audit_service
|
|
from services.database import async_session_maker
|
|
from utils.datetime_utils import get_current_utc_datetime
|
|
|
|
|
|
class RetentionService:
|
|
|
|
async def run_cleanup(self) -> dict:
|
|
"""Soft-delete presentations older than each client's retention_days.
|
|
|
|
Intended to be run daily via ARQ cron.
|
|
Returns summary of actions taken.
|
|
"""
|
|
deleted_count = 0
|
|
errors = 0
|
|
|
|
async with async_session_maker() as session:
|
|
# Find all clients with a retention policy
|
|
stmt = select(ClientModel).where(ClientModel.retention_days.isnot(None))
|
|
result = await session.execute(stmt)
|
|
clients = list(result.scalars().all())
|
|
|
|
for client in clients:
|
|
try:
|
|
cutoff = datetime.now(timezone.utc) - timedelta(
|
|
days=client.retention_days
|
|
)
|
|
# Find non-deleted presentations older than cutoff
|
|
pres_stmt = select(PresentationModel).where(
|
|
and_(
|
|
PresentationModel.client_id == client.id,
|
|
PresentationModel.created_at < cutoff,
|
|
PresentationModel.deleted_at.is_(None),
|
|
)
|
|
)
|
|
pres_result = await session.execute(pres_stmt)
|
|
expired = list(pres_result.scalars().all())
|
|
|
|
for presentation in expired:
|
|
presentation.deleted_at = get_current_utc_datetime()
|
|
deleted_count += 1
|
|
|
|
audit_service.log(
|
|
user_id=None,
|
|
action="retention_delete",
|
|
resource_type="presentation",
|
|
resource_id=presentation.id,
|
|
client_id=client.id,
|
|
details={
|
|
"retention_days": client.retention_days,
|
|
"created_at": presentation.created_at.isoformat()
|
|
if presentation.created_at
|
|
else None,
|
|
},
|
|
)
|
|
|
|
await session.commit()
|
|
except Exception as e:
|
|
errors += 1
|
|
print(f"Retention cleanup error for client {client.id}: {e}")
|
|
|
|
return {"deleted": deleted_count, "errors": errors}
|
|
|
|
async def purge_soft_deleted(self, days_after_soft_delete: int = 30) -> dict:
|
|
"""Permanently delete records that were soft-deleted more than N days ago.
|
|
|
|
Intended to be run weekly via ARQ cron.
|
|
"""
|
|
purged_count = 0
|
|
|
|
async with async_session_maker() as session:
|
|
cutoff = datetime.now(timezone.utc) - timedelta(
|
|
days=days_after_soft_delete
|
|
)
|
|
stmt = select(PresentationModel).where(
|
|
and_(
|
|
PresentationModel.deleted_at.isnot(None),
|
|
PresentationModel.deleted_at < cutoff,
|
|
)
|
|
)
|
|
result = await session.execute(stmt)
|
|
expired = list(result.scalars().all())
|
|
|
|
for presentation in expired:
|
|
# Delete associated export files if they exist
|
|
self._cleanup_files(presentation)
|
|
await session.delete(presentation)
|
|
purged_count += 1
|
|
|
|
await session.commit()
|
|
|
|
return {"purged": purged_count}
|
|
|
|
def _cleanup_files(self, presentation: PresentationModel) -> None:
|
|
"""Remove associated files from filesystem."""
|
|
try:
|
|
if presentation.file_paths:
|
|
for path in presentation.file_paths:
|
|
if path and os.path.exists(path):
|
|
os.remove(path)
|
|
except Exception as e:
|
|
print(f"File cleanup error for presentation {presentation.id}: {e}")
|