feat: production launch — email audit, team invites, security headers, router fixes
Some checks failed
CI / ruff (push) Successful in 9s
CI / pytest (push) Failing after 47m32s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Fix loyalty & monitoring router bugs (_get_router → named routers)
- Implement team invitation email with send_template + seed templates (en/fr/de)
- Add SecurityHeadersMiddleware (nosniff, HSTS, referrer-policy, permissions-policy)
- Build email audit admin page: service, schemas, API, page route, menu, i18n, HTML, JS
- Clean stale TODO in platform-menu-config.js
- Add 67 tests (unit + integration) covering all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 18:24:30 +01:00
parent 4ebd419987
commit ce822af883
25 changed files with 2485 additions and 19 deletions

View File

@@ -17,6 +17,7 @@ from dataclasses import dataclass
from typing import Any
from jinja2 import BaseLoader, Environment, TemplateError
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions.base import (
@@ -677,6 +678,146 @@ class EmailTemplateService:
"body_text": rendered_text,
}
# =========================================================================
# EMAIL LOG OPERATIONS (Audit)
# =========================================================================
def get_email_logs(
self,
filters: dict[str, Any] | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[dict[str, Any]], int]:
"""
Get paginated email logs with optional filters.
Args:
filters: Optional dict with keys: search, status, template_code,
store_id, date_from, date_to
skip: Offset for pagination
limit: Max results per page
Returns:
Tuple of (log items list, total count)
"""
query = self.db.query(EmailLog).order_by(EmailLog.created_at.desc())
if filters:
if filters.get("search"):
search = f"%{filters['search']}%"
query = query.filter(EmailLog.recipient_email.ilike(search))
if filters.get("status"):
query = query.filter(EmailLog.status == filters["status"])
if filters.get("template_code"):
query = query.filter(EmailLog.template_code == filters["template_code"])
if filters.get("store_id"):
query = query.filter(EmailLog.store_id == int(filters["store_id"]))
if filters.get("date_from"):
query = query.filter(EmailLog.created_at >= filters["date_from"])
if filters.get("date_to"):
query = query.filter(EmailLog.created_at <= filters["date_to"])
total = query.count()
logs = query.offset(skip).limit(limit).all()
items = []
for log in logs:
items.append({
"id": log.id,
"recipient_email": log.recipient_email,
"recipient_name": log.recipient_name,
"subject": log.subject,
"status": log.status,
"template_code": log.template_code,
"provider": log.provider,
"store_id": log.store_id,
"related_type": log.related_type,
"related_id": log.related_id,
"created_at": log.created_at.isoformat() if log.created_at else None,
"sent_at": log.sent_at.isoformat() if log.sent_at else None,
"error_message": log.error_message,
})
return items, total
def get_email_log_detail(self, log_id: int) -> dict[str, Any]:
"""
Get full detail for a single email log including body content.
Args:
log_id: Email log ID
Returns:
Full log details including body_html/body_text
Raises:
ResourceNotFoundException: If log not found
"""
log = self.db.query(EmailLog).filter(EmailLog.id == log_id).first()
if not log:
raise ResourceNotFoundException("EmailLog", str(log_id))
return {
"id": log.id,
"recipient_email": log.recipient_email,
"recipient_name": log.recipient_name,
"subject": log.subject,
"status": log.status,
"template_code": log.template_code,
"provider": log.provider,
"store_id": log.store_id,
"user_id": log.user_id,
"related_type": log.related_type,
"related_id": log.related_id,
"from_email": log.from_email,
"from_name": log.from_name,
"reply_to": log.reply_to,
"body_html": log.body_html,
"body_text": log.body_text,
"error_message": log.error_message,
"retry_count": log.retry_count,
"provider_message_id": log.provider_message_id,
"created_at": log.created_at.isoformat() if log.created_at else None,
"sent_at": log.sent_at.isoformat() if log.sent_at else None,
"delivered_at": log.delivered_at.isoformat() if log.delivered_at else None,
"opened_at": log.opened_at.isoformat() if log.opened_at else None,
"clicked_at": log.clicked_at.isoformat() if log.clicked_at else None,
"extra_data": log.extra_data,
}
def get_email_log_stats(self) -> dict[str, Any]:
"""
Get aggregate email log statistics.
Returns:
Dict with by_status, by_template, and total count.
"""
# Count by status
status_rows = (
self.db.query(EmailLog.status, func.count(EmailLog.id))
.group_by(EmailLog.status)
.all()
)
by_status = {row[0]: row[1] for row in status_rows}
# Count by template_code
template_rows = (
self.db.query(EmailLog.template_code, func.count(EmailLog.id))
.filter(EmailLog.template_code.isnot(None))
.group_by(EmailLog.template_code)
.all()
)
by_template = {row[0]: row[1] for row in template_rows}
total = sum(by_status.values())
return {
"by_status": by_status,
"by_template": by_template,
"total": total,
}
# =========================================================================
# HELPER METHODS
# =========================================================================