mock server, file and folder management

This commit is contained in:
2026-06-12 02:18:30 +02:00
parent b67c8abdc5
commit f3db5fc5cf
28 changed files with 3049 additions and 6 deletions

View File

@@ -6,6 +6,8 @@ from .campaigns import router as campaigns_router
from .audit import router as audit_router
from .system import router as system_router
from .mail import router as mail_router
from .files import router as files_router
from .dev_mail import router as dev_mail_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
@@ -14,3 +16,5 @@ router.include_router(admin_router)
router.include_router(audit_router)
router.include_router(system_router)
router.include_router(mail_router)
router.include_router(files_router)
router.include_router(dev_mail_router)

View File

@@ -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 [],
)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from app.auth.dependencies import ApiPrincipal, require_scope
from app.mailer.dev.mock_mailbox import (
clear_records,
get_failures,
get_record,
list_records,
set_failures,
)
router = APIRouter(prefix="/dev/mailbox", tags=["dev-mailbox"])
class MockMailboxListResponse(BaseModel):
messages: list[dict[str, Any]]
class MockMailboxMessageResponse(BaseModel):
message: dict[str, Any]
class MockMailboxClearResponse(BaseModel):
deleted_count: int
class MockMailboxFailureConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
fail_next_smtp: bool | None = None
fail_next_imap: bool | None = None
smtp_reject_recipients_containing: str | None = Field(default=None, max_length=255)
class MockMailboxFailureResponse(BaseModel):
config: dict[str, Any]
@router.get("/messages", response_model=MockMailboxListResponse)
def list_mock_mailbox_messages(
kind: str | None = None,
limit: int = 100,
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
"""List messages captured by the integrated development mail sandbox."""
del principal
return MockMailboxListResponse(messages=list_records(kind=kind, limit=limit))
@router.get("/messages/{message_id}", response_model=MockMailboxMessageResponse)
def get_mock_mailbox_message(
message_id: str,
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
del principal
message = get_record(message_id, include_raw=True)
if not message:
raise HTTPException(status_code=404, detail="Mock mailbox message not found")
return MockMailboxMessageResponse(message=message)
@router.delete("/messages", response_model=MockMailboxClearResponse)
def clear_mock_mailbox_messages(
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
del principal
return MockMailboxClearResponse(deleted_count=clear_records())
@router.get("/failures", response_model=MockMailboxFailureResponse)
def get_mock_failure_config(
principal: ApiPrincipal = Depends(require_scope("campaign:read")),
):
del principal
return MockMailboxFailureResponse(config=get_failures())
@router.post("/failures", response_model=MockMailboxFailureResponse)
def update_mock_failure_config(
payload: MockMailboxFailureConfig,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
del principal
config = set_failures(
fail_next_smtp=payload.fail_next_smtp,
fail_next_imap=payload.fail_next_imap,
smtp_reject_recipients_containing=payload.smtp_reject_recipients_containing,
)
return MockMailboxFailureResponse(config=config)

641
server/app/api/v1/files.py Normal file
View File

@@ -0,0 +1,641 @@
from __future__ import annotations
from io import BytesIO
from typing import Any, Literal
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.dependencies import ApiPrincipal, require_scope
from app.db.models import Campaign, FileAsset, FileFolder, FileShare, Group
from app.db.session import get_session
from app.storage.paths import UnsafeFilePathError, filename_from_path, normalize_folder, normalize_logical_path
from app.storage.services import (
FileStorageError,
asset_is_audit_relevant,
build_rename_preview,
create_file_asset,
create_folder,
create_zip_bytes,
current_version_and_blob,
extract_zip_upload,
get_asset_for_user,
list_assets_for_user,
list_folders_for_user,
rename_asset,
resolve_patterns,
share_file,
soft_delete_assets,
soft_delete_folder,
user_group_ids,
read_asset_bytes,
)
router = APIRouter(prefix="/files", tags=["files"])
class FileSpaceResponse(BaseModel):
id: str
label: str
owner_type: Literal["user", "group"]
owner_id: str
description: str | None = None
class FileSpacesResponse(BaseModel):
spaces: list[FileSpaceResponse]
class FileShareResponse(BaseModel):
id: str
target_type: str
target_id: str
permission: str
created_at: str
revoked_at: str | None = None
class FileAssetResponse(BaseModel):
id: str
tenant_id: str
owner_type: str
owner_id: str
display_path: str
filename: str
description: str | None = None
size_bytes: int
content_type: str | None = None
checksum_sha256: str
version_id: str
created_at: str
updated_at: str
deleted_at: str | None = None
audit_relevant: bool = False
metadata: dict[str, Any] | None = None
shares: list[FileShareResponse] = Field(default_factory=list)
class FileFolderResponse(BaseModel):
id: str
tenant_id: str
owner_type: str
owner_id: str
path: str
created_at: str
updated_at: str
deleted_at: str | None = None
class FileFoldersResponse(BaseModel):
folders: list[FileFolderResponse]
class FileFolderCreateRequest(BaseModel):
owner_type: Literal["user", "group"]
owner_id: str
path: str
class FileFolderDeleteRequest(BaseModel):
owner_type: Literal["user", "group"]
owner_id: str
path: str
recursive: bool = True
class FileFolderDeleteResponse(BaseModel):
deleted_folders: int
deleted_files: int
class FileListResponse(BaseModel):
files: list[FileAssetResponse]
class FileUploadResponse(BaseModel):
files: list[FileAssetResponse]
class BulkDeleteRequest(BaseModel):
file_ids: list[str]
class BulkDeleteResponse(BaseModel):
deleted_count: int
class FileShareRequest(BaseModel):
target_type: Literal["user", "group", "campaign", "tenant"]
target_id: str
permission: Literal["read", "write", "manage"] = "read"
class RenameRequest(BaseModel):
file_ids: list[str]
mode: Literal["prefix", "suffix", "replace"]
find: str | None = None
replacement: str = ""
prefix: str = ""
suffix: str = ""
dry_run: bool = True
class RenamePreviewItem(BaseModel):
file_id: str
old_path: str
new_path: str
class RenameResponse(BaseModel):
dry_run: bool
items: list[RenamePreviewItem]
class ArchiveRequest(BaseModel):
file_ids: list[str]
filename: str = "files.zip"
class PatternResolveRequest(BaseModel):
patterns: list[str]
owner_type: Literal["user", "group"] | None = None
owner_id: str | None = None
campaign_id: str | None = None
path_prefix: str | None = None
include_unmatched: bool = True
class PatternMatchResponse(BaseModel):
pattern: str
matches: list[FileAssetResponse]
class PatternResolveResponse(BaseModel):
patterns: list[PatternMatchResponse]
unmatched: list[FileAssetResponse] = Field(default_factory=list)
def _is_admin(principal: ApiPrincipal) -> bool:
return principal.user.is_tenant_admin or "*" in set(principal.scopes or [])
def _http_error(exc: Exception, *, not_found: bool = False) -> HTTPException:
code = status.HTTP_404_NOT_FOUND if not_found else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=code, detail=str(exc))
def _owner_id(asset: FileAsset) -> str:
return asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id # type: ignore[return-value]
def _asset_response(session: Session, asset: FileAsset, *, include_shares: bool = False) -> FileAssetResponse:
version, blob = current_version_and_blob(session, asset)
shares: list[FileShareResponse] = []
if include_shares:
rows = session.query(FileShare).filter(FileShare.file_asset_id == asset.id).order_by(FileShare.created_at.desc()).all()
shares = [
FileShareResponse(
id=row.id,
target_type=row.target_type,
target_id=row.target_id,
permission=row.permission,
created_at=row.created_at.isoformat(),
revoked_at=row.revoked_at.isoformat() if row.revoked_at else None,
)
for row in rows
]
return FileAssetResponse(
id=asset.id,
tenant_id=asset.tenant_id,
owner_type=asset.owner_type,
owner_id=_owner_id(asset),
display_path=asset.display_path,
filename=asset.filename,
description=asset.description,
size_bytes=blob.size_bytes,
content_type=blob.content_type,
checksum_sha256=blob.checksum_sha256,
version_id=version.id,
created_at=asset.created_at.isoformat(),
updated_at=asset.updated_at.isoformat(),
deleted_at=asset.deleted_at.isoformat() if asset.deleted_at else None,
audit_relevant=asset_is_audit_relevant(session, asset),
metadata=asset.metadata_ or {},
shares=shares,
)
def _folder_owner_id(folder: FileFolder) -> str:
return folder.owner_user_id if folder.owner_type == "user" else folder.owner_group_id # type: ignore[return-value]
def _folder_response(folder: FileFolder) -> FileFolderResponse:
return FileFolderResponse(
id=folder.id,
tenant_id=folder.tenant_id,
owner_type=folder.owner_type,
owner_id=_folder_owner_id(folder),
path=folder.path,
created_at=folder.created_at.isoformat(),
updated_at=folder.updated_at.isoformat(),
deleted_at=folder.deleted_at.isoformat() if folder.deleted_at else None,
)
def _ensure_list_owner_access(session: Session, principal: ApiPrincipal, owner_type: str | None, owner_id: str | None) -> None:
if not owner_type:
return
if owner_type == "user" and owner_id and owner_id != principal.user.id and not _is_admin(principal):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this user file space")
if owner_type == "group" and owner_id:
try:
from app.storage.services import ensure_group_access
ensure_group_access(
session,
tenant_id=principal.tenant_id,
group_id=owner_id,
user_id=principal.user.id,
is_admin=_is_admin(principal),
)
except FileStorageError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
@router.get("/spaces", response_model=FileSpacesResponse)
def list_file_spaces(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
spaces = [
FileSpaceResponse(
id=f"user:{principal.user.id}",
label="My files",
owner_type="user",
owner_id=principal.user.id,
description="Files owned by your user account.",
)
]
group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal))
if group_ids:
groups = session.query(Group).filter(Group.tenant_id == principal.tenant_id, Group.id.in_(group_ids)).order_by(Group.name.asc()).all()
spaces.extend(
FileSpaceResponse(
id=f"group:{group.id}",
label=f"{group.name} files",
owner_type="group",
owner_id=group.id,
description="Files owned by this group.",
)
for group in groups
)
return FileSpacesResponse(spaces=spaces)
@router.get("/folders", response_model=FileFoldersResponse)
def list_file_folders(
owner_type: Literal["user", "group"],
owner_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
folders = list_folders_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
owner_type=owner_type,
owner_id=owner_id,
is_admin=_is_admin(principal),
)
return FileFoldersResponse(folders=[_folder_response(folder) for folder in folders])
except FileStorageError as exc:
raise _http_error(exc) from exc
@router.post("/folders", response_model=FileFolderResponse)
def create_file_folder(
payload: FileFolderCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
folder = create_folder(
session,
tenant_id=principal.tenant_id,
owner_type=payload.owner_type,
owner_id=payload.owner_id,
user_id=principal.user.id,
path=payload.path,
is_admin=_is_admin(principal),
)
session.commit()
return _folder_response(folder)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/folders/delete", response_model=FileFolderDeleteResponse)
def delete_file_folder(
payload: FileFolderDeleteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
deleted_folders, deleted_files = soft_delete_folder(
session,
tenant_id=principal.tenant_id,
owner_type=payload.owner_type,
owner_id=payload.owner_id,
user_id=principal.user.id,
path=payload.path,
recursive=payload.recursive,
is_admin=_is_admin(principal),
)
session.commit()
return FileFolderDeleteResponse(deleted_folders=deleted_folders, deleted_files=deleted_files)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.get("", response_model=FileListResponse)
def list_files(
owner_type: Literal["user", "group"] | None = None,
owner_id: str | None = None,
campaign_id: str | None = None,
path_prefix: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
_ensure_list_owner_access(session, principal, owner_type, owner_id)
assets = list_assets_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
is_admin=_is_admin(principal),
)
return FileListResponse(files=[_asset_response(session, asset, include_shares=True) for asset in assets])
@router.post("/upload", response_model=FileUploadResponse)
async def upload_files(
files: list[UploadFile] = FastAPIFile(...),
owner_type: Literal["user", "group"] = Form(default="user"),
owner_id: str | None = Form(default=None),
path: str = Form(default=""),
campaign_id: str | None = Form(default=None),
unpack_zip: bool = Form(default=False),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
target_owner = owner_id or principal.user.id
uploaded_assets: list[FileAsset] = []
try:
for upload in files:
data = await upload.read()
filename = upload.filename or "file"
content_type = upload.content_type or None
if unpack_zip and filename.lower().endswith(".zip"):
extracted = extract_zip_upload(
session,
tenant_id=principal.tenant_id,
owner_type=owner_type,
owner_id=target_owner,
user_id=principal.user.id,
zip_data=data,
folder=path,
campaign_id=campaign_id,
is_admin=_is_admin(principal),
)
uploaded_assets.extend(item.asset for item in extracted)
continue
stored = create_file_asset(
session,
tenant_id=principal.tenant_id,
owner_type=owner_type,
owner_id=target_owner,
user_id=principal.user.id,
filename=filename,
data=data,
folder=path,
content_type=content_type,
campaign_id=campaign_id,
is_admin=_is_admin(principal),
)
uploaded_assets.append(stored.asset)
session.commit()
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
return FileUploadResponse(files=[_asset_response(session, asset, include_shares=True) for asset in uploaded_assets])
@router.post("/upload-zip", response_model=FileUploadResponse)
async def upload_zip(
file: UploadFile = FastAPIFile(...),
owner_type: Literal["user", "group"] = Form(default="user"),
owner_id: str | None = Form(default=None),
path: str = Form(default=""),
campaign_id: str | None = Form(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
data = await file.read()
target_owner = owner_id or principal.user.id
try:
extracted = extract_zip_upload(
session,
tenant_id=principal.tenant_id,
owner_type=owner_type,
owner_id=target_owner,
user_id=principal.user.id,
zip_data=data,
folder=path,
campaign_id=campaign_id,
is_admin=_is_admin(principal),
)
session.commit()
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
return FileUploadResponse(files=[_asset_response(session, item.asset, include_shares=True) for item in extracted])
@router.get("/{file_id}", response_model=FileAssetResponse)
def get_file(
file_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal))
return _asset_response(session, asset, include_shares=True)
except FileStorageError as exc:
raise _http_error(exc, not_found=True) from exc
@router.get("/{file_id}/download")
def download_file(
file_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal))
data, _, blob = read_asset_bytes(session, asset)
except FileStorageError as exc:
raise _http_error(exc, not_found=True) from exc
headers = {"Content-Disposition": f'attachment; filename="{asset.filename}"'}
return StreamingResponse(BytesIO(data), media_type=blob.content_type or "application/octet-stream", headers=headers)
@router.delete("/{file_id}", response_model=BulkDeleteResponse)
def delete_file(
file_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
count = soft_delete_assets(session, [asset])
session.commit()
return BulkDeleteResponse(deleted_count=count)
except FileStorageError as exc:
session.rollback()
raise _http_error(exc, not_found=True) from exc
@router.post("/bulk-delete", response_model=BulkDeleteResponse)
def bulk_delete_files(
payload: BulkDeleteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
assets = [
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
for file_id in payload.file_ids
]
count = soft_delete_assets(session, assets)
session.commit()
return BulkDeleteResponse(deleted_count=count)
except FileStorageError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/{file_id}/shares", response_model=FileShareResponse)
def create_share(
file_id: str,
payload: FileShareRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
share = share_file(
session,
tenant_id=principal.tenant_id,
asset=asset,
target_type=payload.target_type,
target_id=payload.target_id,
permission=payload.permission,
user_id=principal.user.id,
)
session.commit()
return FileShareResponse(
id=share.id,
target_type=share.target_type,
target_id=share.target_id,
permission=share.permission,
created_at=share.created_at.isoformat(),
revoked_at=share.revoked_at.isoformat() if share.revoked_at else None,
)
except FileStorageError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/bulk-rename", response_model=RenameResponse)
def bulk_rename(
payload: RenameRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
assets = [
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
for file_id in payload.file_ids
]
previews = [
RenamePreviewItem(
file_id=asset.id,
old_path=asset.display_path,
new_path=normalize_logical_path(build_rename_preview(asset, mode=payload.mode, find=payload.find, replacement=payload.replacement, prefix=payload.prefix, suffix=payload.suffix)),
)
for asset in assets
]
if not payload.dry_run:
by_id = {asset.id: asset for asset in assets}
for item in previews:
rename_asset(by_id[item.file_id], new_path=item.new_path)
session.add(by_id[item.file_id])
session.commit()
return RenameResponse(dry_run=payload.dry_run, items=previews)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/archive.zip")
def download_archive(
payload: ArchiveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
try:
assets = [
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal))
for file_id in payload.file_ids
]
data = create_zip_bytes(session, assets)
except FileStorageError as exc:
raise _http_error(exc) from exc
filename = filename_from_path(normalize_logical_path(payload.filename, fallback_filename="files.zip"))
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return StreamingResponse(BytesIO(data), media_type="application/zip", headers=headers)
@router.post("/resolve-patterns", response_model=PatternResolveResponse)
def resolve_file_patterns(
payload: PatternResolveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
):
_ensure_list_owner_access(session, principal, payload.owner_type, payload.owner_id)
try:
assets = list_assets_for_user(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
owner_type=payload.owner_type,
owner_id=payload.owner_id,
campaign_id=payload.campaign_id,
path_prefix=payload.path_prefix,
is_admin=_is_admin(principal),
)
resolved, unmatched = resolve_patterns(assets, payload.patterns, base_path=payload.path_prefix)
return PatternResolveResponse(
patterns=[PatternMatchResponse(pattern=item.pattern, matches=[_asset_response(session, asset) for asset in item.matches]) for item in resolved],
unmatched=[_asset_response(session, asset) for asset in unmatched] if payload.include_unmatched else [],
)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
raise _http_error(exc) from exc

View File

@@ -227,6 +227,22 @@ class SendCampaignNowResponse(BaseModel):
result: dict[str, Any]
class MockCampaignSendRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: str | None = None
send: bool = False
include_warnings: bool = True
include_needs_review: bool = False
append_sent: bool = True
clear_mailbox: bool = False
check_files: bool = False
class MockCampaignSendResponse(BaseModel):
result: dict[str, Any]
class AppendSentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")