- Add cover image CRUD endpoints: GET/POST/DELETE /api/projects/{id}/cover
- Add output generation endpoints: POST /api/projects/{id}/output/{format}
- Add cover image and output queue service methods
- Add CoverImageResponse and CoverImageUploadResponse schemas
- Add umbra-api.js request handling for FormData (cover uploads)
- Add output generation buttons (EPUB/PDF/Audio) to Format UI in umbra-app.js
- Add backtranslate.py tool for verifying translation quality via back-translation
- Add cover image support to render.py EPUB generation
- Copy cover image to deal-hunter-zh source directory
- Spanish translation pipeline started (running in background)
664 lines
18 KiB
Python
664 lines
18 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Public API schemas and their mapping from internal views.
|
|
|
|
The boundary keeps internal invariants out of the wire. ``from_view``
|
|
constructors are the only place an internal representation is trusted to become
|
|
wire JSON; route handlers assemble responses rather than dumping domain objects.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from .errors import InputError
|
|
from .models import (
|
|
AccountStatus,
|
|
CredentialType,
|
|
EnrollmentMode,
|
|
OrganizationStatus,
|
|
ProjectStatus,
|
|
RoleName,
|
|
)
|
|
|
|
|
|
class RequestModel(BaseModel):
|
|
"""Base for request bodies.
|
|
|
|
Unknown fields are rejected so a mistyped property cannot be silently ignored
|
|
or, worse, mask a client bug while the server does something unexpected.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class ResponseModel(BaseModel):
|
|
"""Base for responses.
|
|
|
|
Extra fields are not allowed, so the wire shape cannot drift without the
|
|
tests catching it.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class UserResponse(ResponseModel):
|
|
id: UUID
|
|
email: str
|
|
username: str
|
|
display_name: str
|
|
role: RoleName
|
|
status: AccountStatus
|
|
organization_id: UUID | None
|
|
is_email_verified: bool
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
@classmethod
|
|
def from_view(cls, view) -> UserResponse:
|
|
return cls(
|
|
id=view.id,
|
|
email=view.email,
|
|
username=view.username,
|
|
display_name=view.display_name,
|
|
role=view.role,
|
|
status=view.status,
|
|
organization_id=view.organization_id,
|
|
is_email_verified=getattr(view, "is_email_verified", False),
|
|
created_at=getattr(view, "created_at", None),
|
|
updated_at=getattr(view, "updated_at", None),
|
|
)
|
|
|
|
|
|
|
|
class CredentialSummaryResponse(ResponseModel):
|
|
credential_type: CredentialType
|
|
must_change_password: bool
|
|
failed_login_count: int
|
|
locked: bool
|
|
last_login_at: datetime | None = None
|
|
password_changed_at: datetime | None = None
|
|
|
|
|
|
class AccountResponse(ResponseModel):
|
|
id: UUID
|
|
email: str
|
|
username: str
|
|
display_name: str
|
|
role: RoleName
|
|
status: AccountStatus
|
|
organization_id: UUID | None
|
|
is_email_verified: bool
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
credential: CredentialSummaryResponse
|
|
active_session_count: int = 0
|
|
|
|
|
|
class AccountListResponse(ResponseModel):
|
|
accounts: list[AccountResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class CreateAccountRequest(RequestModel):
|
|
"""Administrator account creation for either enrollment mode.
|
|
|
|
The request never carries a password: the schema forbids unknown fields, so
|
|
a submitted ``password`` is rejected outright. For
|
|
``enrollment_mode="temporary_password"`` the service generates the one-time
|
|
password and returns it exactly once. For ``enrollment_mode="oidc"`` no
|
|
password is stored at all.
|
|
"""
|
|
|
|
enrollment_mode: EnrollmentMode
|
|
username: str = Field(min_length=1, max_length=64)
|
|
display_name: str = Field(min_length=1, max_length=128)
|
|
email: str = Field(min_length=3, max_length=320)
|
|
role: RoleName = RoleName.MEMBER
|
|
organization_id: UUID | None = None
|
|
|
|
|
|
class CreateAccountResponse(ResponseModel):
|
|
account: AccountResponse
|
|
temporary_password: str | None = None
|
|
|
|
|
|
class StatusChangeRequest(RequestModel):
|
|
status: AccountStatus
|
|
reason: str | None = Field(default=None, max_length=500)
|
|
|
|
|
|
SetAccountStatusRequest = StatusChangeRequest
|
|
|
|
|
|
class StatusChangeResponse(ResponseModel):
|
|
account: AccountResponse
|
|
revoked_sessions: int = 0
|
|
|
|
|
|
class RevokeSessionsResponse(ResponseModel):
|
|
account: AccountResponse
|
|
revoked_sessions: int = 0
|
|
|
|
|
|
class PasswordResetRequestedResponse(ResponseModel):
|
|
requested: bool
|
|
|
|
|
|
class SetupStatusResponse(ResponseModel):
|
|
setup_required: bool
|
|
|
|
|
|
class SetupInitializeRequest(RequestModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
display_name: str = Field(min_length=1, max_length=128)
|
|
username: str | None = Field(default=None, max_length=64)
|
|
password: str = Field(min_length=1, max_length=1024)
|
|
|
|
|
|
class SetupInitializeResponse(ResponseModel):
|
|
account: AccountResponse
|
|
csrf_token: str | None = None
|
|
|
|
|
|
class BootstrapAdminRequest(RequestModel):
|
|
username: str | None = Field(default=None, max_length=64)
|
|
display_name: str = Field(min_length=1, max_length=128)
|
|
email: str = Field(min_length=3, max_length=320)
|
|
organization_id: UUID | None = None
|
|
role: RoleName = RoleName.PLATFORM_ADMIN
|
|
|
|
|
|
class LoginRequest(RequestModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
password: str = Field(min_length=1, max_length=1024)
|
|
|
|
|
|
class ChangeFirstPasswordRequest(RequestModel):
|
|
current_password: str = Field(min_length=1, max_length=1024)
|
|
new_password: str = Field(min_length=1, max_length=1024)
|
|
|
|
|
|
class ChangeOwnPasswordRequest(RequestModel):
|
|
current_password: str = Field(min_length=1, max_length=1024)
|
|
new_password: str = Field(min_length=1, max_length=1024)
|
|
|
|
|
|
class VerifyEmailRequest(RequestModel):
|
|
token: str = Field(min_length=1, max_length=4096)
|
|
|
|
|
|
class ResendVerificationRequest(RequestModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
|
|
|
|
class LoginResponse(ResponseModel):
|
|
user: UserResponse
|
|
must_change_password: bool
|
|
csrf_token: str | None = None
|
|
idle_expires_at: datetime | None = None
|
|
absolute_expires_at: datetime | None = None
|
|
|
|
|
|
class SessionResponse(ResponseModel):
|
|
user: UserResponse
|
|
must_change_password: bool
|
|
idle_expires_at: datetime | None = None
|
|
absolute_expires_at: datetime | None = None
|
|
|
|
|
|
class VerifyEmailResponse(ResponseModel):
|
|
account: AccountResponse
|
|
activated: bool
|
|
|
|
|
|
class ResendVerificationResponse(ResponseModel):
|
|
requested: bool
|
|
|
|
|
|
class OrganizationResponse(ResponseModel):
|
|
id: UUID
|
|
name: str
|
|
slug: str
|
|
status: OrganizationStatus
|
|
member_count: int
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
@classmethod
|
|
def from_view(cls, view) -> OrganizationResponse:
|
|
return cls(
|
|
id=view.id,
|
|
name=view.name,
|
|
slug=view.slug,
|
|
status=view.status,
|
|
member_count=view.member_count,
|
|
created_at=getattr(view, "created_at", None),
|
|
updated_at=getattr(view, "updated_at", None),
|
|
)
|
|
|
|
|
|
class OrganizationCreateRequest(RequestModel):
|
|
name: str = Field(min_length=1, max_length=128)
|
|
slug: str | None = Field(default=None, max_length=64)
|
|
status: OrganizationStatus = OrganizationStatus.ACTIVE
|
|
|
|
|
|
CreateOrganizationRequest = OrganizationCreateRequest
|
|
|
|
|
|
class OrganizationUpdateRequest(RequestModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
|
slug: str | None = Field(default=None, max_length=64)
|
|
status: OrganizationStatus | None = None
|
|
|
|
|
|
UpdateOrganizationRequest = OrganizationUpdateRequest
|
|
SetOrganizationStatusRequest = OrganizationUpdateRequest
|
|
|
|
|
|
|
|
|
|
class OrganizationListResponse(ResponseModel):
|
|
organizations: list[OrganizationResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class ProjectResponse(ResponseModel):
|
|
"""A project as returned by the API.
|
|
|
|
``assigned_account_ids`` is who is *working* on the project. It is not a
|
|
visibility filter: within an organization every project is visible to every
|
|
account, so this says who has it rather than who may look. See
|
|
:mod:`umbratrans_web.tenancy`.
|
|
"""
|
|
|
|
id: UUID
|
|
organization_id: UUID
|
|
name: str
|
|
slug: str
|
|
status: ProjectStatus
|
|
assigned_account_ids: list[UUID] = Field(default_factory=list)
|
|
assigned_count: int = 0
|
|
created_at: datetime | None = None
|
|
created_by: UUID | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
@classmethod
|
|
def from_view(cls, view) -> ProjectResponse:
|
|
return cls(
|
|
id=view.id,
|
|
organization_id=view.organization_id,
|
|
name=view.name,
|
|
slug=view.slug,
|
|
status=view.status,
|
|
assigned_account_ids=list(getattr(view, "assigned_account_ids", []) or []),
|
|
assigned_count=getattr(view, "assigned_count", 0) or 0,
|
|
created_at=getattr(view, "created_at", None),
|
|
created_by=getattr(view, "created_by", None),
|
|
updated_at=getattr(view, "updated_at", None),
|
|
)
|
|
|
|
|
|
class ProjectCreateRequest(RequestModel):
|
|
name: str = Field(min_length=1, max_length=128)
|
|
slug: str | None = Field(default=None, max_length=64)
|
|
organization_id: UUID | None = None
|
|
|
|
|
|
CreateProjectRequest = ProjectCreateRequest
|
|
|
|
|
|
class ProjectStatusRequest(RequestModel):
|
|
status: ProjectStatus
|
|
|
|
|
|
SetProjectStatusRequest = ProjectStatusRequest
|
|
|
|
|
|
class ProjectListResponse(ResponseModel):
|
|
projects: list[ProjectResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class ProjectAssignmentRequest(RequestModel):
|
|
account_id: UUID
|
|
|
|
|
|
class ProjectAssignmentResponse(ResponseModel):
|
|
project: ProjectResponse
|
|
|
|
|
|
class OrganizationSummaryResponse(ResponseModel):
|
|
total_organizations: int
|
|
active_organizations: int
|
|
total_accounts: int
|
|
active_accounts: int
|
|
suspended_accounts: int
|
|
pending_first_login: int
|
|
organization_admin_count: int
|
|
|
|
|
|
class AuditEventResponse(ResponseModel):
|
|
id: str
|
|
occurred_at: datetime
|
|
action: str
|
|
outcome: str
|
|
actor_user_id: UUID | None = None
|
|
target_user_id: UUID | None = None
|
|
organization_id: UUID | None = None
|
|
request_id: str | None = None
|
|
detail: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class AuditListResponse(ResponseModel):
|
|
items: list[AuditEventResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class WorkspaceProjectResponse(ProjectResponse):
|
|
"""A project row inside ``/api/workspace`` with per-caller assignment state."""
|
|
|
|
assigned_to_me: bool = False
|
|
|
|
|
|
class WorkspaceResponse(ResponseModel):
|
|
"""``GET /api/workspace``: the signed-in viewer's organization and its work."""
|
|
|
|
user: UserResponse
|
|
organization: OrganizationResponse | None
|
|
projects: list[WorkspaceProjectResponse]
|
|
total: int
|
|
assigned_to_me: list[WorkspaceProjectResponse]
|
|
|
|
|
|
class ProjectDetailResponse(ResponseModel):
|
|
project: ProjectResponse
|
|
languages: list[dict[str, Any]]
|
|
review: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ProjectStatusLanguageResponse(ResponseModel):
|
|
language_tag: str
|
|
language_label: str
|
|
role: str
|
|
source_present: bool
|
|
translation: dict[str, Any]
|
|
audio: dict[str, Any]
|
|
outputs: dict[str, Any]
|
|
review: dict[str, Any]
|
|
updated_at: str | None = None
|
|
|
|
|
|
class ProjectStatusResponse(ResponseModel):
|
|
project_id: str
|
|
languages: list[ProjectStatusLanguageResponse]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Translation queue
|
|
|
|
|
|
class TranslationQueueRequest(RequestModel):
|
|
language_tag: str = Field(min_length=1, max_length=16)
|
|
|
|
|
|
class TranslationQueueResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_tag: str
|
|
state: str
|
|
job_id: str | None = None
|
|
queued_at: datetime | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Audio queue
|
|
|
|
|
|
class AudioQueueRequest(RequestModel):
|
|
language_tag: str = Field(min_length=1, max_length=16)
|
|
voice_config: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class AudioQueueResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_tag: str
|
|
state: str
|
|
job_id: str | None = None
|
|
queued_at: datetime | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Format metadata (EPUB / PDF)
|
|
|
|
|
|
class FormatMetadataRequest(RequestModel):
|
|
language_tag: str = Field(min_length=1, max_length=16)
|
|
cover: str | None = None
|
|
isbn: str | None = Field(default=None, max_length=64)
|
|
blurb: str | None = None
|
|
amazon_search_terms: str | None = None
|
|
print_profile: str | None = Field(default=None, max_length=64)
|
|
page_count: str | None = Field(default=None, max_length=32)
|
|
|
|
|
|
class FormatMetadataResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_tag: str
|
|
saved: bool
|
|
updated_at: datetime | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utterance review by chapter
|
|
|
|
|
|
class UtteranceReviewResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_tag: str
|
|
chapter_id: str
|
|
utterances: list[dict[str, Any]]
|
|
speakers: list[dict[str, Any]]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Front matter (per language)
|
|
|
|
|
|
class FrontMatterItemRequest(RequestModel):
|
|
key: str = Field(min_length=1, max_length=128)
|
|
label: str = Field(min_length=1, max_length=128)
|
|
value: str | None = None
|
|
output_page: str | None = Field(default=None, max_length=16)
|
|
|
|
|
|
class FrontMatterItemResponse(ResponseModel):
|
|
key: str
|
|
label: str
|
|
value: str | None = None
|
|
output_page: str | None = None
|
|
|
|
|
|
class FrontMatterRequest(RequestModel):
|
|
language_tag: str = Field(min_length=1, max_length=16)
|
|
items: list[FrontMatterItemRequest] = Field(default_factory=list)
|
|
|
|
|
|
class FrontMatterResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_tag: str
|
|
items: list[FrontMatterItemResponse]
|
|
version: int = 1
|
|
|
|
|
|
class HealthResponse(ResponseModel):
|
|
status: str
|
|
service: str
|
|
version: str
|
|
environment: str
|
|
repository: str
|
|
cookie_secure: bool
|
|
csrf_required: bool
|
|
mail_deliverable: bool
|
|
mail_delivery_waived: bool
|
|
setup_required: bool
|
|
time: datetime | None = None
|
|
|
|
|
|
class LogoutResponse(ResponseModel):
|
|
logged_out: bool = True
|
|
|
|
|
|
class ResetPasswordRequest(RequestModel):
|
|
token: str = Field(min_length=1, max_length=4096)
|
|
new_password: str = Field(min_length=1, max_length=1024)
|
|
|
|
|
|
class PasswordResetAvailableResponse(ResponseModel):
|
|
available: bool
|
|
|
|
|
|
class SessionUserResponse(ResponseModel):
|
|
user: UserResponse
|
|
role: RoleName
|
|
organization_id: UUID | None = None
|
|
must_change_password: bool = False
|
|
csrf_token: str | None = None
|
|
|
|
|
|
|
|
class OrganizationStatusChangeResponse(ResponseModel):
|
|
organization: OrganizationResponse
|
|
revoked_sessions: int = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Organization create (controller integration endpoint)
|
|
|
|
|
|
class OrganizationCreateRequest(RequestModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
slug: str | None = Field(default=None, max_length=64)
|
|
|
|
|
|
class OrganizationCreateResponse(ResponseModel):
|
|
organization: OrganizationResponse
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Program create (controller integration endpoint)
|
|
|
|
|
|
class ProgramCreateRequest(RequestModel):
|
|
organization_id: UUID
|
|
name: str = Field(min_length=1, max_length=120)
|
|
slug: str | None = Field(default=None, max_length=64)
|
|
source_language_tag: str = Field(default="en", min_length=2, max_length=16)
|
|
|
|
|
|
class ProgramCreateResponse(ResponseModel):
|
|
project: ProjectResponse
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utterance translation (per-language text for controller-audio integration)
|
|
|
|
|
|
class UtteranceTranslationItem(RequestModel):
|
|
utterance_id: str = Field(min_length=1, max_length=128)
|
|
translated_text: str = Field(min_length=1)
|
|
speaker_name: str | None = None
|
|
status: str = Field(default="draft", max_length=32)
|
|
source_unit_key: str | None = Field(default=None, max_length=128)
|
|
|
|
|
|
class UtteranceTranslationRequest(RequestModel):
|
|
language_code: str = Field(min_length=2, max_length=16)
|
|
language_name: str = Field(min_length=1, max_length=120)
|
|
translations: list[UtteranceTranslationItem] = Field(default_factory=list)
|
|
|
|
|
|
class UtteranceTranslationResponse(ResponseModel):
|
|
id: UUID
|
|
utterance_id: str
|
|
language_code: str
|
|
language_name: str
|
|
text: str
|
|
status: str
|
|
origin: str
|
|
source_ref: str | None = None
|
|
delivered_at: datetime | None = None
|
|
confirmed_at: datetime | None = None
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
voiced: bool = True
|
|
review_reason: str | None = None
|
|
speaker_name: str | None = None
|
|
source_unit_key: str | None = None
|
|
|
|
|
|
class UtteranceTranslationListResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_code: str
|
|
language_name: str
|
|
translations: list[UtteranceTranslationResponse]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Speaker sync (controller integration endpoint)
|
|
|
|
|
|
class SpeakerSyncItem(RequestModel):
|
|
external_id: str
|
|
display_name: str
|
|
role: str = Field(default="character", max_length=32)
|
|
aliases: list[str] = Field(default_factory=list)
|
|
voice_ref_path: str | None = None
|
|
|
|
|
|
class SpeakerSyncRequest(RequestModel):
|
|
speakers: list[SpeakerSyncItem] = Field(default_factory=list)
|
|
|
|
|
|
class SpeakerSyncResponse(ResponseModel):
|
|
project_id: UUID
|
|
created: int
|
|
updated: int
|
|
aliases_added: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cover image (controller integration endpoint)
|
|
|
|
|
|
class CoverImageResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_tag: str | None = None
|
|
has_cover: bool
|
|
cover_path: str | None = None
|
|
content_type: str | None = None
|
|
size_bytes: int | None = None
|
|
|
|
|
|
class CoverImageUploadResponse(ResponseModel):
|
|
project_id: UUID
|
|
language_tag: str | None = None
|
|
saved: bool
|
|
cover_path: str
|
|
size_bytes: int
|
|
|