attachment backend use

This commit is contained in:
2026-06-13 04:14:10 +02:00
parent 36e9211ee6
commit fe5ac084b7
11 changed files with 696 additions and 145 deletions

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import copy
import re
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
@@ -38,7 +37,10 @@ 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.storage.files import current_version_and_blob
from app.storage.campaign_attachments import managed_match_payloads, prepared_campaign_snapshot
from app.mailer.campaign.loader import load_campaign_config
from app.mailer.attachments.resolver import resolve_campaign_attachments
from app.mailer.persistence.versions import (
LockedCampaignVersionError,
create_minimal_campaign,
@@ -969,6 +971,7 @@ def append_sent(
class CampaignAttachmentPreviewRequest(BaseModel):
include_unmatched: bool = True
campaign_json: dict[str, object] | None = None
class CampaignAttachmentPreviewResponse(BaseModel):
@@ -979,44 +982,19 @@ class CampaignAttachmentPreviewResponse(BaseModel):
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]:
def _file_preview(session: Session, asset) -> dict[str, object]:
version, blob = current_version_and_blob(session, asset)
return {
"id": asset.id,
"version_id": version.id,
"blob_id": blob.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,
"checksum_sha256": blob.checksum_sha256,
"size_bytes": blob.size_bytes,
"content_type": blob.content_type,
}
@@ -1033,61 +1011,77 @@ def preview_campaign_attachments(
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]] = []
payload = payload or CampaignAttachmentPreviewRequest()
raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json
raw = raw if isinstance(raw, dict) else {}
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(
with prepared_campaign_snapshot(
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)
raw_json=raw,
include_bytes=False,
prefix="multimailer-managed-preview-",
) as prepared:
config = load_campaign_config(prepared.path)
report = resolve_campaign_attachments(config, campaign_file=prepared.path)
rules: list[dict[str, object]] = []
matched_asset_ids: set[str] = set()
for entry in report.entries:
for attachment in entry.attachments:
managed_matches = managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path)
matched_asset_ids.update(str(item["asset_id"]) for item in managed_matches)
matches: list[dict[str, object]] = [
{
"id": item["asset_id"],
"version_id": item["version_id"],
"blob_id": item["blob_id"],
"display_path": item["display_path"],
"filename": item["filename"],
"owner_type": item["owner_type"],
"owner_id": item["owner_id"],
"checksum_sha256": item["checksum_sha256"],
"size_bytes": item["size_bytes"],
"content_type": item["content_type"],
}
for item in managed_matches
]
if not matches:
matches = [
{
"id": "",
"display_path": match,
"filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1],
"owner_type": "legacy",
"owner_id": "",
}
for match in attachment.matches
]
rules.append({
"source": attachment.scope.value,
"entry_index": entry.entry_index,
"entry_id": entry.entry_id,
"index": attachment.index,
"attachment_id": attachment.attachment_id,
"label": attachment.label,
"required": attachment.required,
"pattern": attachment.file_filter,
"base_path_name": attachment.base_path_name,
"base_path": attachment.base_path,
"status": attachment.status.value,
"behavior": attachment.behavior.value if attachment.behavior else None,
"matches": matches,
"match_count": len(matches),
"issues": [issue.model_dump(mode="json") for issue in attachment.issues],
})
unused = [asset for asset in prepared.shared_assets if asset.id not in matched_asset_ids]
return CampaignAttachmentPreviewResponse(
campaign_id=campaign.id,
version_id=version.id,
shared_file_count=len(prepared.shared_assets),
rules=rules,
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
)
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 [],
)