inital commit
This commit is contained in:
0
server/app/api/__init__.py
Normal file
0
server/app/api/__init__.py
Normal file
12
server/app/api/v1/__init__.py
Normal file
12
server/app/api/v1/__init__.py
Normal 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)
|
||||
37
server/app/api/v1/admin.py
Normal file
37
server/app/api/v1/admin.py
Normal 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,
|
||||
)
|
||||
32
server/app/api/v1/audit.py
Normal file
32
server/app/api/v1/audit.py
Normal 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])
|
||||
651
server/app/api/v1/campaigns.py
Normal file
651
server/app/api/v1/campaigns.py
Normal 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
|
||||
202
server/app/api/v1/schemas.py
Normal file
202
server/app/api/v1/schemas.py
Normal 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]
|
||||
25
server/app/api/v1/system.py
Normal file
25
server/app/api/v1/system.py
Normal 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"))
|
||||
Reference in New Issue
Block a user