feat: production launch — email audit, team invites, security headers, router fixes
- 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:
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user