ppt-tool/backend/services/retention_service.py
Vadym Samoilenko c97841f6d1 Phase 6: Export & Polish — brand export, client dashboard, retention, analytics
- 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>
2026-02-26 16:41:58 +00:00

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}")