Files
UmbraTrans/umbratrans_web/routes_workspace.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

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
)