Files
UmbraTrans/umbratrans_web/schemas.py
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text

Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
2026-10-25 11:04:53 -07:00

551 lines
14 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