inital commit

This commit is contained in:
2026-06-08 15:57:11 +02:00
parent aaf8729663
commit d9ca48addc
114 changed files with 12172 additions and 1 deletions

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from .admin import router as admin_router
from .campaigns import router as campaigns_router
from .audit import router as audit_router
from .system import router as system_router
router = APIRouter(prefix="/api/v1")
router.include_router(campaigns_router)
router.include_router(admin_router)
router.include_router(audit_router)
router.include_router(system_router)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.v1.schemas import ApiKeyCreateRequest, ApiKeyCreateResponse
from app.auth.dependencies import ApiPrincipal, require_scope
from app.audit.logging import audit_from_principal
from app.db.session import get_session
from app.security.api_keys import create_api_key
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/api-keys", response_model=ApiKeyCreateResponse)
def create_personal_api_key(
payload: ApiKeyCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:settings")),
):
created = create_api_key(session, user=principal.user, name=payload.name, scopes=payload.scopes or ["campaign:read"])
audit_from_principal(
session,
principal,
action="api_key.created",
object_type="api_key",
object_id=created.model.id,
details={"name": created.model.name, "prefix": created.model.prefix, "scopes": created.model.scopes},
commit=True,
)
return ApiKeyCreateResponse(
id=created.model.id,
name=created.model.name,
prefix=created.model.prefix,
scopes=created.model.scopes,
secret=created.secret,
)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.v1.schemas import AuditLogItemResponse, AuditLogListResponse
from app.auth.dependencies import ApiPrincipal, require_scope
from app.db.models import AuditLog
from app.db.session import get_session
router = APIRouter(prefix="/audit", tags=["audit"])
@router.get("", response_model=AuditLogListResponse)
def list_audit_log(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
action: str | None = None,
object_type: str | None = None,
object_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("audit:read")),
):
query = session.query(AuditLog).filter(AuditLog.tenant_id == principal.tenant_id)
if action:
query = query.filter(AuditLog.action == action)
if object_type:
query = query.filter(AuditLog.object_type == object_type)
if object_id:
query = query.filter(AuditLog.object_id == object_id)
items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
return AuditLogListResponse(items=[AuditLogItemResponse.model_validate(item) for item in items])

View File

@@ -0,0 +1,651 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app.api.v1.schemas import (
BuildCampaignRequest,
CampaignCreateRequest,
CampaignCreateResponse,
CampaignCreateMinimalRequest,
CampaignJobsResponse,
CampaignListResponse,
CampaignResponse,
CampaignVersionDetailResponse,
CampaignVersionResponse,
CampaignVersionSetStepRequest,
CampaignVersionUpdateRequest,
CampaignPartialValidationRequest,
CampaignPartialValidationResponse,
ValidateCampaignRequest,
ReportEmailRequest,
ReportEmailResponse,
)
from app.auth.dependencies import ApiPrincipal, require_scope
from app.audit.logging import audit_from_principal
from app.db.models import Campaign, CampaignJob, CampaignVersion
from app.db.session import get_session
from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from app.mailer.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from app.mailer.persistence.campaigns import (
CampaignPersistenceError,
build_campaign_version,
create_campaign_version_from_json,
validate_campaign_version,
)
from app.mailer.persistence.versions import (
create_minimal_campaign,
get_campaign_version_for_tenant,
publish_campaign_version,
update_campaign_version,
validate_campaign_partial,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign:
campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
return campaign
def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -> CampaignVersion:
version = session.get(CampaignVersion, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
return version
@router.post("", response_model=CampaignCreateResponse)
def create_campaign(
payload: CampaignCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
campaign, version = create_campaign_version_from_json(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
raw_json=payload.config,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
)
audit_from_principal(
session,
principal,
action="campaign.created",
object_type="campaign",
object_id=campaign.id,
details={"version_id": version.id, "external_id": campaign.external_id},
commit=True,
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
@router.post("/new", response_model=CampaignCreateResponse)
def create_minimal_campaign_endpoint(
payload: CampaignCreateMinimalRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Create a minimal editable campaign/version for the WebUI wizard.
This is intentionally different from importing a complete campaign JSON. It
returns a normal Campaign + CampaignVersion whose version is a working copy
and can be autosaved while incomplete.
"""
try:
campaign, version = create_minimal_campaign(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
external_id=payload.external_id,
name=payload.name,
description=payload.description,
current_flow=payload.current_flow,
current_step=payload.current_step,
)
audit_from_principal(
session,
principal,
action="campaign.created_minimal",
object_type="campaign",
object_id=campaign.id,
details={"version_id": version.id, "external_id": campaign.external_id},
commit=True,
)
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.get("", response_model=CampaignListResponse)
def list_campaigns(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
campaigns = (
session.query(Campaign)
.filter(Campaign.tenant_id == principal.tenant_id)
.order_by(Campaign.updated_at.desc())
.all()
)
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id))
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
def list_versions(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
versions = (
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc())
.all()
)
return [CampaignVersionResponse.model_validate(item) for item in versions]
@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
def get_version_detail(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
try:
version = get_campaign_version_for_tenant(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
)
return CampaignVersionDetailResponse.model_validate(version)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
def update_version_detail(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow,
current_step=payload.current_step,
workflow_state=payload.workflow_state,
is_complete=payload.is_complete,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=False,
)
audit_from_principal(
session,
principal,
action="campaign.version_updated",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse)
def autosave_version(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=payload.campaign_json,
current_flow=payload.current_flow,
current_step=payload.current_step,
workflow_state=payload.workflow_state,
is_complete=payload.is_complete,
editor_state=payload.editor_state,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
autosave=True,
)
audit_from_principal(
session,
principal,
action="campaign.version_autosaved",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse)
def set_version_step(
campaign_id: str,
version_id: str,
payload: CampaignVersionSetStepRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
current_flow=payload.current_flow,
current_step=payload.current_step,
autosave=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse)
def validate_version_partial(
campaign_id: str,
version_id: str,
payload: CampaignPartialValidationRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
):
try:
version = get_campaign_version_for_tenant(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
)
campaign_json = payload.campaign_json if payload and payload.campaign_json is not None else version.raw_json
result = validate_campaign_partial(campaign_json, section=payload.section if payload else None)
audit_from_principal(
session,
principal,
action="campaign.version_partially_validated",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id, "section": result.get("section"), "ok": result.get("ok")},
commit=True,
)
return CampaignPartialValidationResponse(**result)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/{campaign_id}/versions/{version_id}/publish", response_model=CampaignVersionDetailResponse)
def publish_version(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
try:
version = publish_campaign_version(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id)
audit_from_principal(
session,
principal,
action="campaign.version_published",
object_type="campaign_version",
object_id=version.id,
details={"campaign_id": campaign_id},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/versions/{version_id}/validate")
def validate_version(
version_id: str,
payload: ValidateCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
):
try:
result = validate_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
check_files=payload.check_files if payload else False,
)
audit_from_principal(
session,
principal,
action="campaign.validated",
object_type="campaign_version",
object_id=version_id,
details={"check_files": payload.check_files if payload else False, "ok": result.get("ok")},
commit=True,
)
return result
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/versions/{version_id}/build")
def build_version(
version_id: str,
payload: BuildCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:build")),
):
try:
result = build_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=payload.write_eml if payload else True,
)
audit_from_principal(
session,
principal,
action="campaign.messages_built",
object_type="campaign_version",
object_id=version_id,
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
commit=True,
)
return result
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
def list_jobs(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_id == campaign.id)
.order_by(CampaignJob.entry_index.asc())
.all()
)
return CampaignJobsResponse(
jobs=[
{
"id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"build_status": job.build_status,
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
"eml_local_path": job.eml_local_path,
"eml_size_bytes": job.eml_size_bytes,
"attempt_count": job.attempt_count,
"last_error": job.last_error,
"queued_at": job.queued_at,
"sent_at": job.sent_at,
"issues": job.issues_snapshot,
"attachments": job.resolved_attachments,
}
for job in jobs
]
)
@router.get("/{campaign_id}/summary")
def campaign_summary(
campaign_id: str,
include_jobs: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
"""Return dashboard-friendly campaign status counters and summaries."""
try:
return generate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
include_jobs=include_jobs,
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get("/{campaign_id}/report")
def campaign_report(
campaign_id: str,
include_jobs: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
"""Return the full JSON report for one campaign."""
try:
return generate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
include_jobs=include_jobs,
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get("/{campaign_id}/report/jobs.csv")
def campaign_jobs_csv(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
"""Export per-job campaign status as CSV."""
try:
csv_text = generate_jobs_csv(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return Response(
content=csv_text,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'},
)
@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse)
def email_campaign_report(
campaign_id: str,
payload: ReportEmailRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("reports:send")),
):
"""Generate a campaign report and send it to one or more email addresses."""
try:
result = send_campaign_report_email(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
to=payload.to,
include_jobs=payload.include_jobs,
attach_jobs_csv=payload.attach_jobs_csv,
attach_report_json=payload.attach_report_json,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="report.email_sent" if not payload.dry_run else "report.email_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result.as_dict(),
commit=True,
)
return ReportEmailResponse(result=result.as_dict())
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except (CampaignReportEmailError, Exception) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
# Queue / delivery control -------------------------------------------------
from app.api.v1.schemas import AppendSentRequest, CampaignActionResponse, QueueCampaignRequest, QueueCampaignResponse
from app.mailer.sending.jobs import (
QueueingError,
cancel_campaign_jobs,
enqueue_pending_imap_appends,
pause_campaign_jobs,
queue_campaign_jobs,
resume_campaign_jobs,
)
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
def queue_campaign(
campaign_id: str,
payload: QueueCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
payload = payload or QueueCampaignRequest()
try:
result = queue_campaign_jobs(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=payload.version_id,
include_warnings=payload.include_warnings,
enqueue_celery=payload.enqueue_celery,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.queued" if not payload.dry_run else "campaign.queue_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result.as_dict(),
commit=True,
)
return QueueCampaignResponse(**result.as_dict())
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse)
def pause_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
try:
result = pause_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
audit_from_principal(session, principal, action="campaign.paused", object_type="campaign", object_id=campaign_id, details=result, commit=True)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse)
def resume_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
try:
result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse)
def cancel_campaign(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:queue")),
):
try:
result = cancel_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
audit_from_principal(session, principal, action="campaign.cancelled", object_type="campaign", object_id=campaign_id, details=result, commit=True)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse)
def append_sent(
campaign_id: str,
payload: AppendSentRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaign:send")),
):
payload = payload or AppendSentRequest()
try:
result = enqueue_pending_imap_appends(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
enqueue_celery=payload.enqueue_celery,
dry_run=payload.dry_run,
)
audit_from_principal(
session,
principal,
action="campaign.append_sent_enqueued" if not payload.dry_run else "campaign.append_sent_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
commit=True,
)
return CampaignActionResponse(result=result)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class CampaignCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
config: dict[str, Any]
source_filename: str | None = None
source_base_path: str | None = None
class CampaignCreateMinimalRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
external_id: str
name: str
description: str | None = None
current_flow: str = "create"
current_step: str = "basics"
class CampaignVersionUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
campaign_json: dict[str, Any] | None = None
current_flow: str | None = None
current_step: str | None = None
workflow_state: str | None = None
is_complete: bool | None = None
editor_state: dict[str, Any] | None = None
source_filename: str | None = None
source_base_path: str | None = None
class CampaignVersionSetStepRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
current_flow: str | None = None
current_step: str
class CampaignPartialValidationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
campaign_json: dict[str, Any] | None = None
section: str | None = None
class CampaignVersionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
campaign_id: str
version_number: int
schema_version: str
source_filename: str | None = None
source_base_path: str | None = None
workflow_state: str = "editing"
current_flow: str = "manual"
current_step: str | None = None
is_complete: bool = False
editor_state: dict[str, Any] = Field(default_factory=dict)
autosaved_at: datetime | None = None
published_at: datetime | None = None
locked_at: datetime | None = None
locked_by_user_id: str | None = None
created_at: datetime
updated_at: datetime
validation_summary: dict[str, Any] | None = None
build_summary: dict[str, Any] | None = None
class CampaignVersionDetailResponse(CampaignVersionResponse):
raw_json: dict[str, Any]
class CampaignPartialValidationResponse(BaseModel):
ok: bool
section: str | None = None
error_count: int
warning_count: int
info_count: int
issues: list[dict[str, Any]]
class CampaignResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
external_id: str
name: str
description: str | None = None
status: str
current_version_id: str | None = None
created_at: datetime
updated_at: datetime
class CampaignCreateResponse(BaseModel):
campaign: CampaignResponse
version: CampaignVersionResponse
class CampaignListResponse(BaseModel):
campaigns: list[CampaignResponse]
class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]]
class ValidateCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
check_files: bool = False
class BuildCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
write_eml: bool = True
class ApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
scopes: list[str] = Field(default_factory=list)
class ApiKeyCreateResponse(BaseModel):
id: str
name: str
prefix: str
scopes: list[str]
secret: str
class QueueCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: str | None = None
include_warnings: bool = True
enqueue_celery: bool = True
dry_run: bool = False
class QueueCampaignResponse(BaseModel):
campaign_id: str
version_id: str
queued_count: int
skipped_count: int
blocked_count: int
enqueued_count: int
dry_run: bool = False
class AppendSentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enqueue_celery: bool = True
dry_run: bool = False
class CampaignActionResponse(BaseModel):
result: dict[str, Any]
class ReportEmailRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
to: list[str]
include_jobs: bool = False
attach_jobs_csv: bool = True
attach_report_json: bool = False
dry_run: bool = False
class ReportEmailResponse(BaseModel):
result: dict[str, Any]
class AuditLogItemResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
user_id: str | None = None
api_key_id: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] | None = None
created_at: datetime
class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse]

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends
from app.auth.dependencies import ApiPrincipal, require_scope
router = APIRouter(prefix="/schemas", tags=["schemas"])
@router.get("/campaign")
def get_campaign_schema(
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
) -> dict[str, Any]:
"""Return the authoritative campaign JSON Schema used by the backend.
The WebUI can fetch this instead of carrying a stale copy. A future UI
schema can be served alongside this endpoint.
"""
schema_path = Path(__file__).resolve().parents[2] / "mailer" / "schemas" / "campaign.schema.json"
return json.loads(schema_path.read_text(encoding="utf-8"))