mock server, file and folder management
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.v1.schemas import (
|
||||
BuildCampaignRequest,
|
||||
@@ -34,12 +38,14 @@ from app.mailer.persistence.campaigns import (
|
||||
create_campaign_version_from_json,
|
||||
validate_campaign_version,
|
||||
)
|
||||
from app.storage.services import list_assets_for_user, resolve_patterns
|
||||
from app.mailer.persistence.versions import (
|
||||
LockedCampaignVersionError,
|
||||
create_minimal_campaign,
|
||||
fork_campaign_version_for_edit,
|
||||
is_version_final_locked,
|
||||
is_user_locked_version,
|
||||
is_version_locked,
|
||||
get_campaign_version_for_tenant,
|
||||
publish_campaign_version,
|
||||
unlock_validated_campaign_version,
|
||||
@@ -67,6 +73,37 @@ def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -
|
||||
return version
|
||||
|
||||
|
||||
|
||||
|
||||
def _sync_campaign_metadata_to_current_version(session: Session, campaign: Campaign) -> None:
|
||||
"""Keep editable version JSON aligned with version-independent campaign metadata.
|
||||
|
||||
Campaign metadata can be edited from the overview while individual campaign
|
||||
sections save the current version JSON later. Without this sync, a later
|
||||
version save can re-apply stale `campaign.name` / `campaign.id` values from
|
||||
raw_json and make the old overview metadata appear to come back. Audit-safe
|
||||
or validation-locked versions are left untouched.
|
||||
"""
|
||||
|
||||
if not campaign.current_version_id:
|
||||
return
|
||||
|
||||
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if not version or version.campaign_id != campaign.id or is_version_locked(version):
|
||||
return
|
||||
|
||||
raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
campaign_section = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||
raw_json["campaign"] = {
|
||||
**campaign_section,
|
||||
"id": campaign.external_id,
|
||||
"name": campaign.name,
|
||||
"description": campaign.description or "",
|
||||
}
|
||||
version.raw_json = raw_json
|
||||
session.add(version)
|
||||
|
||||
|
||||
@router.post("", response_model=CampaignCreateResponse)
|
||||
def create_campaign(
|
||||
payload: CampaignCreateRequest,
|
||||
@@ -187,6 +224,8 @@ def update_campaign_metadata_endpoint(
|
||||
campaign.status = payload.status
|
||||
if payload.description is not None:
|
||||
campaign.description = payload.description
|
||||
|
||||
_sync_campaign_metadata_to_current_version(session, campaign)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
session.refresh(campaign)
|
||||
@@ -690,7 +729,10 @@ from app.api.v1.schemas import (
|
||||
QueueCampaignResponse,
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
MockCampaignSendRequest,
|
||||
MockCampaignSendResponse,
|
||||
)
|
||||
from app.mailer.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
||||
from app.mailer.sending.jobs import (
|
||||
QueueingError,
|
||||
cancel_campaign_jobs,
|
||||
@@ -734,6 +776,55 @@ def queue_campaign(
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
|
||||
def mock_send_campaign(
|
||||
campaign_id: str,
|
||||
payload: MockCampaignSendRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaign:send")),
|
||||
):
|
||||
"""Run a fully visible mock delivery flow without mutating campaign state.
|
||||
|
||||
The route validates and builds the selected version, then optionally records
|
||||
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
|
||||
real SMTP/IMAP servers and it does not mark the version sent/final.
|
||||
"""
|
||||
|
||||
payload = payload or MockCampaignSendRequest()
|
||||
try:
|
||||
result = run_mock_campaign_send(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
send=payload.send,
|
||||
include_warnings=payload.include_warnings,
|
||||
include_needs_review=payload.include_needs_review,
|
||||
append_sent=payload.append_sent,
|
||||
clear_mailbox=payload.clear_mailbox,
|
||||
check_files=payload.check_files,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.mock_send" if payload.send else "campaign.mock_send_review",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
"version_id": result.get("version_id"),
|
||||
"send_requested": payload.send,
|
||||
"sent_count": result.get("send", {}).get("sent_count"),
|
||||
"failed_count": result.get("send", {}).get("failed_count"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return MockCampaignSendResponse(result=result)
|
||||
except MockCampaignSendError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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}/send-now", response_model=SendCampaignNowResponse)
|
||||
def send_campaign_now_endpoint(
|
||||
campaign_id: str,
|
||||
@@ -756,7 +847,7 @@ def send_campaign_now_endpoint(
|
||||
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None
|
||||
build_result: dict[str, object] | None = None
|
||||
build_result: dict[str, object] | None = version.build_summary if isinstance(version.build_summary, dict) else None
|
||||
if is_user_locked_version(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
@@ -767,7 +858,7 @@ def send_campaign_now_endpoint(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Campaign version must be validated and locked before dry-run or sending.",
|
||||
)
|
||||
if not version.build_summary:
|
||||
if not build_result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Campaign version must be built before dry-run or sending.",
|
||||
@@ -874,3 +965,129 @@ def append_sent(
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
class CampaignAttachmentPreviewRequest(BaseModel):
|
||||
include_unmatched: bool = True
|
||||
|
||||
|
||||
class CampaignAttachmentPreviewResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
shared_file_count: int
|
||||
rules: list[dict[str, object]] = Field(default_factory=list)
|
||||
unused_shared_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
_BRACE_PLACEHOLDER_RE = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
_DOLLAR_PLACEHOLDER_RE = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
|
||||
|
||||
def _preview_render_template(template: str, *, global_values: dict[str, object], entry_fields: dict[str, object]) -> str:
|
||||
def value_for(raw_key: str) -> str:
|
||||
key = raw_key.strip()
|
||||
if key.startswith("local:"):
|
||||
value = entry_fields.get(key.removeprefix("local:"), "")
|
||||
elif key.startswith("local."):
|
||||
value = entry_fields.get(key.removeprefix("local."), "")
|
||||
elif key.startswith("global:"):
|
||||
value = global_values.get(key.removeprefix("global:"), "")
|
||||
elif key.startswith("global."):
|
||||
value = global_values.get(key.removeprefix("global."), "")
|
||||
else:
|
||||
value = entry_fields.get(key, global_values.get(key, ""))
|
||||
return "" if value is None else str(value)
|
||||
|
||||
rendered = _BRACE_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), template)
|
||||
return _DOLLAR_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), rendered)
|
||||
|
||||
|
||||
def _rule_pattern(rule: dict[str, object], base_path_names: set[str], *, global_values: dict[str, object], entry_fields: dict[str, object]) -> str:
|
||||
base_dir = _preview_render_template(str(rule.get("base_dir") or "."), global_values=global_values, entry_fields=entry_fields).strip().strip("/")
|
||||
file_filter = _preview_render_template(str(rule.get("file_filter") or "*"), global_values=global_values, entry_fields=entry_fields).strip() or "*"
|
||||
if not base_dir or base_dir == "." or base_dir in base_path_names:
|
||||
return file_filter
|
||||
return f"{base_dir}/{file_filter}"
|
||||
|
||||
|
||||
def _file_preview(asset) -> dict[str, object]:
|
||||
return {
|
||||
"id": asset.id,
|
||||
"display_path": asset.display_path,
|
||||
"filename": asset.filename,
|
||||
"owner_type": asset.owner_type,
|
||||
"owner_id": asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse)
|
||||
def preview_campaign_attachments(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentPreviewRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
|
||||
):
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
|
||||
|
||||
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
attachments = raw.get("attachments") if isinstance(raw.get("attachments"), dict) else {}
|
||||
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
|
||||
base_path_names = {str(item.get("name")) for item in base_paths if isinstance(item, dict) and item.get("name")}
|
||||
global_values = raw.get("global_values") if isinstance(raw.get("global_values"), dict) else {}
|
||||
rules: list[dict[str, object]] = []
|
||||
|
||||
global_rules = attachments.get("global") if isinstance(attachments.get("global"), list) else []
|
||||
for index, rule in enumerate(global_rules):
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
rules.append({
|
||||
"source": "global",
|
||||
"index": index,
|
||||
"label": rule.get("label"),
|
||||
"required": bool(rule.get("required", True)),
|
||||
"pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields={}),
|
||||
})
|
||||
|
||||
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
|
||||
inline_entries = entries.get("inline") if isinstance(entries.get("inline"), list) else []
|
||||
for entry_index, entry in enumerate(inline_entries, start=1):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_fields = entry.get("fields") if isinstance(entry.get("fields"), dict) else {}
|
||||
entry_rules = entry.get("attachments") if isinstance(entry.get("attachments"), list) else []
|
||||
for rule_index, rule in enumerate(entry_rules):
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
rules.append({
|
||||
"source": "entry",
|
||||
"entry_index": entry_index,
|
||||
"entry_id": entry.get("id"),
|
||||
"index": rule_index,
|
||||
"label": rule.get("label"),
|
||||
"required": bool(rule.get("required", True)),
|
||||
"pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields=entry_fields),
|
||||
})
|
||||
|
||||
shared_assets = list_assets_for_user(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
campaign_id=campaign.id,
|
||||
is_admin=principal.user.is_tenant_admin or "*" in set(principal.scopes or []),
|
||||
)
|
||||
resolved, unmatched = resolve_patterns(shared_assets, [str(rule["pattern"]) for rule in rules])
|
||||
for rule, result in zip(rules, resolved, strict=False):
|
||||
rule["matches"] = [_file_preview(asset) for asset in result.matches]
|
||||
rule["match_count"] = len(result.matches)
|
||||
|
||||
return CampaignAttachmentPreviewResponse(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
shared_file_count=len(shared_assets),
|
||||
rules=rules,
|
||||
unused_shared_files=[_file_preview(asset) for asset in unmatched] if payload is None or payload.include_unmatched else [],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user