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
303 lines
10 KiB
Python
303 lines
10 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""``/api/workspace``: the signed-in caller's organization and its work.
|
|
|
|
Separate from ``/api/auth`` and ``/api/uadmin`` on purpose. Authentication answers
|
|
"who are you"; administration answers "what may you change". Neither answers
|
|
"what does your organization look like today", which is what every signed-in
|
|
person opens the application to find, and which must work for a plain member with
|
|
no administrative role at all.
|
|
|
|
One endpoint, scoped server-side, returning the organization and its projects
|
|
together. A dashboard assembled in the browser from several calls is a dashboard
|
|
that can show two tenants at once if a filter is mistimed or a response is cached
|
|
from a previous sign-in - and the pieces would disagree while nothing noticed.
|
|
Here the caller cannot name an organization, so there is no filter to mistime:
|
|
``tenancy.scope_for`` resolves it from the session and this route never accepts
|
|
one.
|
|
|
|
Membership carries no privilege, so this route requires a session and nothing
|
|
more. It deliberately does not sit behind ``require_admin``: a member's dashboard
|
|
is not a privileged view, and making it privileged would mean either hiding the
|
|
organization from its own members or giving every member administrative rights to
|
|
see it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
|
|
from .app import get_service, require_principal
|
|
from .models import Principal
|
|
from .schemas import (
|
|
AudioQueueResponse,
|
|
FrontMatterResponse,
|
|
FormatMetadataResponse,
|
|
OrganizationResponse,
|
|
ProjectDetailResponse,
|
|
ProjectResponse,
|
|
ProjectStatusResponse,
|
|
TranslationQueueResponse,
|
|
UtteranceReviewResponse,
|
|
UserResponse,
|
|
WorkspaceProjectResponse,
|
|
WorkspaceResponse,
|
|
)
|
|
from .service import AccountService
|
|
from .project_status import ProjectStatusAdapter
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("", response_model=WorkspaceResponse)
|
|
def workspace(
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> WorkspaceResponse:
|
|
"""The caller's organization, its projects, and the projects they are on.
|
|
|
|
``organization`` is ``null`` for a platform administrator, who belongs to no
|
|
tenant. That is a normal answer, not a failure: they see the platform, and
|
|
choosing which organization's work to open is an explicit act rather than a
|
|
default that could quietly become somebody else's.
|
|
"""
|
|
|
|
service: AccountService = get_service(request)
|
|
snapshot = service.workspace(principal)
|
|
|
|
def render(views) -> list[WorkspaceProjectResponse]:
|
|
return [
|
|
WorkspaceProjectResponse(
|
|
**ProjectResponse.from_view(view).model_dump(),
|
|
assigned_to_me=principal.user_id in view.assigned_account_ids,
|
|
)
|
|
for view in views
|
|
]
|
|
|
|
return WorkspaceResponse(
|
|
user=UserResponse.from_view(service.self_view(principal.user_id)),
|
|
organization=OrganizationResponse.from_view(snapshot.organization) if snapshot.organization else None,
|
|
projects=render(snapshot.projects),
|
|
total=snapshot.total,
|
|
assigned_to_me=render(snapshot.assigned_to_me),
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}", response_model=ProjectDetailResponse)
|
|
def project_detail(
|
|
project_id,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> ProjectDetailResponse:
|
|
"""One project and its real state, scoped to the caller."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
project = service.get_project(principal, UUID(str(project_id)))
|
|
adapter = ProjectStatusAdapter(getattr(request.app.state.settings, "productions_root", None))
|
|
status = adapter.status_for(project)
|
|
return ProjectDetailResponse(
|
|
project=ProjectResponse.from_view(project),
|
|
languages=status.get("languages", []),
|
|
review={"needs_attention": any((lang.get("review") or {}).get("state") == "needs_review" for lang in status.get("languages", []))},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/status", response_model=ProjectStatusResponse)
|
|
def project_status(
|
|
project_id,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> ProjectStatusResponse:
|
|
"""Per-language translation/audio/format state, backed by the productions directory."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
project = service.get_project(principal, UUID(str(project_id)))
|
|
adapter = ProjectStatusAdapter(getattr(request.app.state.settings, "productions_root", None))
|
|
status = adapter.status_for(project)
|
|
return ProjectStatusResponse(project_id=status["project_id"], languages=status.get("languages", []))
|
|
|
|
|
|
@router.get("/{project_id}/chapters/{language_tag}", response_model=list[dict[str, Any]])
|
|
def list_chapters(
|
|
project_id: str,
|
|
language_tag: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> list[dict[str, Any]]:
|
|
"""Return chapter metadata for one language project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.get_chapters(principal, UUID(str(project_id)), language_tag)
|
|
|
|
|
|
@router.get("/{project_id}/chapter-content/{language_tag}/{chapter_id}", response_model=dict[str, Any])
|
|
def get_chapter_content(
|
|
project_id: str,
|
|
language_tag: str,
|
|
chapter_id: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> dict[str, Any]:
|
|
"""Return full paragraph content for one chapter (front matter editing)."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.get_chapter_content(principal, UUID(str(project_id)), language_tag, chapter_id)
|
|
|
|
|
|
@router.post("/{project_id}/translation/{language_tag}/queue", response_model=TranslationQueueResponse)
|
|
def queue_translation(
|
|
project_id: str,
|
|
language_tag: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> TranslationQueueResponse:
|
|
"""Queue a translation job for one language project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.queue_translation(principal, UUID(str(project_id)), language_tag)
|
|
|
|
|
|
@router.post("/{project_id}/audio/{language_tag}/queue", response_model=AudioQueueResponse)
|
|
def queue_audio(
|
|
project_id: str,
|
|
language_tag: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
voice_config: dict | None = None,
|
|
) -> AudioQueueResponse:
|
|
"""Queue an audio generation job for one language project.
|
|
|
|
Requires the language's translation to be complete first, matching the
|
|
workflow: translate, then audio.
|
|
"""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.queue_audio(
|
|
principal, UUID(str(project_id)), language_tag, voice_config=voice_config
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/format/{language_tag}", response_model=FormatMetadataResponse)
|
|
def get_format_metadata(
|
|
project_id: str,
|
|
language_tag: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> FormatMetadataResponse:
|
|
"""Read format metadata (EPUB/PDF) for one language project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.get_format_metadata(principal, UUID(str(project_id)), language_tag)
|
|
|
|
|
|
@router.post("/{project_id}/format/{language_tag}", response_model=FormatMetadataResponse)
|
|
def set_format_metadata(
|
|
project_id: str,
|
|
language_tag: str,
|
|
metadata: FormatMetadataRequest,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> FormatMetadataResponse:
|
|
"""Save format metadata (cover, ISBN, blurb, search terms, etc.) for one language project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.set_format_metadata(
|
|
principal,
|
|
UUID(str(project_id)),
|
|
language_tag,
|
|
metadata=metadata.model_dump(exclude={"language_tag"}),
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/front-matter/{language_tag}", response_model=FrontMatterResponse)
|
|
def save_front_matter(
|
|
project_id: str,
|
|
language_tag: str,
|
|
payload: FrontMatterRequest,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> FrontMatterResponse:
|
|
"""Save front-matter items for one language project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
items = [item.model_dump() for item in payload.items]
|
|
return service.save_front_matter(
|
|
principal, UUID(str(project_id)), language_tag, items=items
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{project_id}/review/{language_tag}/chapters/{chapter_id}",
|
|
response_model=UtteranceReviewResponse,
|
|
)
|
|
def get_utterance_review(
|
|
project_id: str,
|
|
language_tag: str,
|
|
chapter_id: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> UtteranceReviewResponse:
|
|
"""Return utterances for one chapter of one language, for speaker-alignment review."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.get_utterance_review(
|
|
principal, UUID(str(project_id)), language_tag, chapter_id
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/front-matter/{language_tag}", response_model=FrontMatterResponse)
|
|
def get_front_matter(
|
|
project_id: str,
|
|
language_tag: str,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> FrontMatterResponse:
|
|
"""Read front-matter items for one language project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
return service.get_front_matter(principal, UUID(str(project_id)), language_tag)
|
|
|
|
|
|
@router.post("/{project_id}/front-matter/{language_tag}", response_model=FrontMatterResponse)
|
|
def save_front_matter(
|
|
project_id: str,
|
|
language_tag: str,
|
|
payload: FrontMatterRequest,
|
|
request: Request,
|
|
principal: Principal = Depends(require_principal),
|
|
) -> FrontMatterResponse:
|
|
"""Save front-matter items for one language project."""
|
|
|
|
service: AccountService = get_service(request)
|
|
from uuid import UUID
|
|
|
|
items = [item.model_dump() for item in payload.items]
|
|
return service.save_front_matter(
|
|
principal, UUID(str(project_id)), language_tag, items=items
|
|
)
|