mock server, file and folder management
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
95
server/app/api/v1/dev_mail.py
Normal file
95
server/app/api/v1/dev_mail.py
Normal 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
641
server/app/api/v1/files.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user