Add UmbraAudio controller integration endpoints
- Add migration 0010: utterance_translations table with UNIQUE(utterance_id, language_code)
- Add schemas: OrganizationCreateRequest/Response, ProgramCreateRequest/Response,
UtteranceTranslationItem/Request/Response/ListResponse, SpeakerSyncRequest/Response
- Add service methods: create_organization, create_program, sync_speakers,
upsert_utterance_translations, get_utterance_translations
- Add routes_controller.py: POST /api/orgs, POST /api/programs,
POST /api/speakers/sync, POST /api/projects/{id}/translations,
GET /api/projects/{id}/translations/{language_code}
- Mount controller_router at /api in app.py
- Create bdumbra account (member, BDMurphy org) for integration testing
This commit is contained in:
@@ -600,6 +600,7 @@ def create_app(
|
||||
from .routes_auth import router as auth_router
|
||||
from .routes_compute import admin_router as compute_router
|
||||
from .routes_compute import agent_router as node_router
|
||||
from .routes_controller import router as controller_router
|
||||
from .routes_setup import router as setup_router
|
||||
from .routes_uadmin import router as uadmin_router
|
||||
from .routes_workspace import router as workspace_router
|
||||
@@ -607,6 +608,10 @@ def create_app(
|
||||
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"])
|
||||
# Controller-integration endpoints for the UmbraAudio node. Mounted at /api
|
||||
# (no workspace prefix) because they are machine-to-machine routes, not
|
||||
# member dashboard routes.
|
||||
app.include_router(controller_router, prefix="/api", tags=["controller"])
|
||||
# Mounted at /api rather than /api/auth: this is the member's own view, not
|
||||
# something they authenticate *to*, and it needs no administrative role.
|
||||
app.include_router(workspace_router, prefix="/api/workspace", tags=["workspace"])
|
||||
|
||||
59
umbratrans_web/migrations/0010_utterance_translations.sql
Normal file
59
umbratrans_web/migrations/0010_utterance_translations.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- 0010: utterance translations — per-language translated text for controller-audio integration.
|
||||
-- Status: PROPOSED
|
||||
-- Additive: one new table, no existing tables altered.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.utterance_translations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES public.projects(id),
|
||||
utterance_id uuid NOT NULL REFERENCES public.translation_utterances(id),
|
||||
language_code text NOT NULL,
|
||||
language_name text NOT NULL,
|
||||
text text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'draft',
|
||||
origin text NOT NULL DEFAULT 'umbratrans',
|
||||
source_ref text,
|
||||
delivered_at timestamptz,
|
||||
confirmed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
voiced boolean NOT NULL DEFAULT true,
|
||||
review_reason text,
|
||||
UNIQUE(utterance_id, language_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS utterance_translations_project_idx
|
||||
ON public.utterance_translations (project_id);
|
||||
CREATE INDEX IF NOT EXISTS utterance_translations_lang_idx
|
||||
ON public.utterance_translations (language_code);
|
||||
CREATE INDEX IF NOT EXISTS utterance_translations_project_lang_idx
|
||||
ON public.utterance_translations (project_id, language_code);
|
||||
CREATE INDEX IF NOT EXISTS utterance_translations_status_idx
|
||||
ON public.utterance_translations (status);
|
||||
|
||||
-- RLS policies, matching the pattern from 0008.
|
||||
DO $rls$
|
||||
BEGIN
|
||||
EXECUTE 'ALTER TABLE public.utterance_translations ENABLE ROW LEVEL SECURITY';
|
||||
EXECUTE 'DROP POLICY IF EXISTS utterance_translations_tenant ON public.utterance_translations';
|
||||
EXECUTE 'CREATE POLICY utterance_translations_tenant ON public.utterance_translations USING (app.is_platform_admin() OR app.is_organization_member(project_id))';
|
||||
END
|
||||
$rls$;
|
||||
|
||||
COMMENT ON TABLE public.utterance_translations IS
|
||||
'Per-language translated text for utterances, imported from the audio node. One row per utterance per language.';
|
||||
|
||||
COMMENT ON COLUMN public.utterance_translations.language_code IS
|
||||
'BCP-47 language tag (e.g. en, zh-Hans).';
|
||||
COMMENT ON COLUMN public.utterance_translations.origin IS
|
||||
'System that originated the translation (umbratrans or umbraaudio).';
|
||||
COMMENT ON COLUMN public.utterance_translations.voiced INTEGER IS
|
||||
'Whether the translation has been voiced (audio generated).';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Rollback, in reverse dependency order. Not applied automatically.
|
||||
-- DROP POLICY IF EXISTS utterance_translations_tenant ON public.utterance_translations;
|
||||
-- DROP TABLE IF EXISTS public.utterance_translations;
|
||||
164
umbratrans_web/routes_controller.py
Normal file
164
umbratrans_web/routes_controller.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# Copyright 2026 Umbra Tools (umbratools.com)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""``/api`` controller-integration endpoints for the UmbraAudio node.
|
||||
|
||||
These endpoints let the UmbraAudio system (audio node) push data into the
|
||||
UmbraTrans controller and retrieve work state back. They are separate from
|
||||
``/api/workspace`` (the member's dashboard view) and ``/api/uadmin`` (the
|
||||
administrator's view) because they serve a machine-to-machine integration
|
||||
contract rather than a human UI.
|
||||
|
||||
Endpoints:
|
||||
|
||||
* ``POST /api/orgs`` — create an organization
|
||||
* ``POST /api/programs`` — create a program (project)
|
||||
* ``POST /api/speakers/sync`` — sync speakers from the audio node's speakers.json
|
||||
* ``POST /api/projects/{id}/translations`` — bulk upsert utterance translations
|
||||
* ``GET /api/projects/{id}/translations/{language_code}`` — get translations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from .app import get_service, require_principal
|
||||
from .models import Principal
|
||||
from .schemas import (
|
||||
OrganizationCreateResponse,
|
||||
OrganizationCreateRequest,
|
||||
ProgramCreateResponse,
|
||||
ProgramCreateRequest,
|
||||
SpeakerSyncResponse,
|
||||
SpeakerSyncRequest,
|
||||
UtteranceTranslationListResponse,
|
||||
UtteranceTranslationRequest,
|
||||
)
|
||||
from .service import AccountService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/orgs", response_model=OrganizationCreateResponse)
|
||||
def create_organization(
|
||||
payload: OrganizationCreateRequest,
|
||||
request: Request,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> OrganizationCreateResponse:
|
||||
"""Create an organization. Platform administrator only."""
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
return service.create_organization(
|
||||
principal,
|
||||
name=payload.name,
|
||||
slug=payload.slug,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/programs", response_model=ProgramCreateResponse)
|
||||
def create_program(
|
||||
payload: ProgramCreateRequest,
|
||||
request: Request,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> ProgramCreateResponse:
|
||||
"""Create a program (project) inside an organization."""
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
return service.create_program(
|
||||
principal,
|
||||
organization_id=payload.organization_id,
|
||||
name=payload.name,
|
||||
slug=payload.slug,
|
||||
source_language_tag=payload.source_language_tag,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/speakers/sync", response_model=SpeakerSyncResponse)
|
||||
def sync_speakers(
|
||||
payload: SpeakerSyncRequest,
|
||||
request: Request,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> SpeakerSyncResponse:
|
||||
"""Sync speaker data from the audio node's speakers.json.
|
||||
|
||||
The audio node posts its full speakers.json payload. The controller
|
||||
stores it in the translations directory's speakers.json (for the
|
||||
file-based pipeline) and in the in-memory job store (for cross-system
|
||||
consistency).
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
# The speaker sync targets the project that the caller's session is
|
||||
# scoped to. The caller must have selected a project context; we derive
|
||||
# it from the workspace snapshot.
|
||||
snapshot = service.workspace(principal)
|
||||
projects = snapshot.projects
|
||||
if not projects:
|
||||
from .errors import NotFoundError
|
||||
raise NotFoundError("project_not_found")
|
||||
# Use the first accessible project as the sync target. The caller should
|
||||
# target a specific project; this is a convenience for single-project
|
||||
# deployments.
|
||||
project = projects[0]
|
||||
result = service.sync_speakers(
|
||||
principal,
|
||||
project.id,
|
||||
[s.model_dump() for s in payload.speakers],
|
||||
)
|
||||
return SpeakerSyncResponse(
|
||||
project_id=project.id,
|
||||
created=result["created"],
|
||||
updated=result["updated"],
|
||||
aliases_added=result["aliases_added"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/translations", response_model=dict)
|
||||
def upsert_utterance_translations(
|
||||
project_id: str,
|
||||
payload: UtteranceTranslationRequest,
|
||||
request: Request,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> dict:
|
||||
"""Bulk upsert utterance translations for one language.
|
||||
|
||||
The audio node posts translated text for every utterance in a chapter
|
||||
or book. The controller stores it keyed by (utterance_id, language_code)
|
||||
so the review UI can display it.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
upserted = service.upsert_utterance_translations(
|
||||
principal,
|
||||
UUID(str(project_id)),
|
||||
payload.language_code,
|
||||
payload.language_name,
|
||||
[t.model_dump() for t in payload.translations],
|
||||
)
|
||||
return {"ok": True, "upserted": upserted}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/translations/{language_code}",
|
||||
response_model=UtteranceTranslationListResponse,
|
||||
)
|
||||
def get_utterance_translations(
|
||||
project_id: str,
|
||||
language_code: str,
|
||||
request: Request,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> UtteranceTranslationListResponse:
|
||||
"""Get all utterance translations for one project and language."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
return service.get_utterance_translations(
|
||||
principal,
|
||||
UUID(str(project_id)),
|
||||
language_code,
|
||||
)
|
||||
@@ -546,5 +546,100 @@ class OrganizationStatusChangeResponse(ResponseModel):
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -116,8 +116,13 @@ from .schemas import (
|
||||
AudioQueueResponse,
|
||||
FrontMatterResponse,
|
||||
FormatMetadataResponse,
|
||||
OrganizationCreateResponse,
|
||||
ProgramCreateResponse,
|
||||
SpeakerSyncResponse,
|
||||
TranslationQueueResponse,
|
||||
UtteranceReviewResponse,
|
||||
UtteranceTranslationListResponse,
|
||||
UtteranceTranslationResponse,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger("umbratrans_web.service")
|
||||
@@ -184,6 +189,10 @@ ACTION_TRANSLATION_QUEUED = "translation.queued"
|
||||
ACTION_AUDIO_QUEUED = "audio.queued"
|
||||
ACTION_FORMAT_METADATA_SAVED = "format.metadata_saved"
|
||||
ACTION_FRONT_MATTER_SAVED = "front_matter.saved"
|
||||
ACTION_ORGANIZATION_CREATED = "organization.created"
|
||||
ACTION_PROGRAM_CREATED = "program.created"
|
||||
ACTION_SPEAKERS_SYNCED = "speakers.synced"
|
||||
ACTION_TRANSLATIONS_UPSERTED = "translations.upserted"
|
||||
ACTION_PASSWORD_RESET_REQUESTED = "password.reset_requested"
|
||||
ACTION_PASSWORD_RESET_COMPLETED = "password.reset_completed"
|
||||
ACTION_SESSION_REVOKED = "session.revoked"
|
||||
@@ -2955,6 +2964,304 @@ class AccountService:
|
||||
assigned = [view for view in projects if actor.user_id in view.assigned_account_ids]
|
||||
return WorkspaceSnapshot(organization=organization, projects=projects, assigned_to_me=assigned, total=total)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Controller integration: organizations and programs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_organization(
|
||||
self,
|
||||
actor: Principal,
|
||||
*,
|
||||
name: str,
|
||||
slug: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> OrganizationView:
|
||||
"""Create an organization for the caller.
|
||||
|
||||
Only a platform administrator may create an organization.
|
||||
"""
|
||||
|
||||
self._require_admin(actor)
|
||||
from .models import normalize_organization_name, normalize_organization_slug
|
||||
now = self._now()
|
||||
organization = Organization(
|
||||
id=uuid4(),
|
||||
name=normalize_organization_name(name),
|
||||
slug=normalize_organization_slug(slug) if slug is not None else normalize_organization_slug(name),
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._repository.create_organization(organization)
|
||||
self._audit(
|
||||
action=ACTION_ORGANIZATION_CREATED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=organization.id,
|
||||
request_id=request_id,
|
||||
detail={"organization_name": organization.name, "organization_slug": organization.slug},
|
||||
)
|
||||
LOGGER.info("organization created organization_id=%s name=%s", organization.id, organization.name)
|
||||
return OrganizationView(
|
||||
id=organization.id,
|
||||
name=organization.name,
|
||||
slug=organization.slug,
|
||||
status=organization.status,
|
||||
member_count=organization.member_count,
|
||||
created_at=organization.created_at,
|
||||
updated_at=organization.updated_at,
|
||||
)
|
||||
|
||||
def create_program(
|
||||
self,
|
||||
actor: Principal,
|
||||
*,
|
||||
organization_id: UUID,
|
||||
name: str,
|
||||
slug: str | None = None,
|
||||
source_language_tag: str = "en",
|
||||
request_id: str | None = None,
|
||||
) -> ProjectView:
|
||||
"""Create a program (project) inside an organization.
|
||||
|
||||
Only a platform administrator or organization administrator may create
|
||||
a program.
|
||||
"""
|
||||
|
||||
self._require_admin(actor)
|
||||
scope = scope_for(actor, organization_id)
|
||||
target = resolve_target(scope, organization_id)
|
||||
from .models import normalize_project_name, normalize_project_slug
|
||||
now = self._now()
|
||||
project = Project(
|
||||
id=uuid4(),
|
||||
organization_id=target,
|
||||
name=normalize_project_name(name),
|
||||
slug=normalize_project_slug(slug) if slug is not None else normalize_project_slug(name),
|
||||
status=ProjectStatus.ACTIVE,
|
||||
created_by=actor.user_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._repository.create_project(project)
|
||||
self._audit(
|
||||
action=ACTION_PROGRAM_CREATED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=target,
|
||||
request_id=request_id,
|
||||
detail={"project_name": project.name, "project_slug": project.slug},
|
||||
)
|
||||
LOGGER.info("program created project_id=%s organization_id=%s", project.id, target)
|
||||
return self._project_view(project)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Controller integration: speaker sync
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def sync_speakers(
|
||||
self,
|
||||
actor: Principal,
|
||||
project_id: UUID,
|
||||
speakers: list[dict],
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Sync speaker data from the audio node's speakers.json.
|
||||
|
||||
Creates or updates character records in the translation_speakers table
|
||||
(via the productions directory's speakers.json) and records the sync
|
||||
in the in-memory job store for cross-system consistency.
|
||||
|
||||
Returns counts: {created, updated, aliases_added}.
|
||||
"""
|
||||
|
||||
scope = scope_for(actor)
|
||||
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
|
||||
if project is None:
|
||||
if scope.unrestricted:
|
||||
raise NotFoundError("project_not_found")
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
aliases_added = 0
|
||||
|
||||
prod_dir = self._productions_dir_for(project)
|
||||
speakers_path = prod_dir / "speakers.json" if prod_dir else None
|
||||
|
||||
if speakers_path and speakers_path.exists():
|
||||
import json
|
||||
with speakers_path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
existing_speakers = data.get("speakers", []) if isinstance(data, dict) else data
|
||||
else:
|
||||
existing_speakers = []
|
||||
|
||||
# Merge incoming speakers with existing
|
||||
speaker_map = {s.get("external_id", s.get("display_name", "")): s for s in existing_speakers}
|
||||
|
||||
for spk in speakers:
|
||||
external_id = spk.get("external_id", "")
|
||||
display_name = spk.get("display_name", "")
|
||||
if not display_name:
|
||||
continue
|
||||
|
||||
if external_id in speaker_map:
|
||||
# Update existing
|
||||
existing = speaker_map[external_id]
|
||||
existing_aliases = set(existing.get("aliases", []))
|
||||
new_aliases = set(spk.get("aliases", []))
|
||||
added = new_aliases - existing_aliases
|
||||
aliases_added += len(added)
|
||||
existing["aliases"] = list(existing_aliases | new_aliases)
|
||||
existing["voice_ref_path"] = spk.get("voice_ref_path", existing.get("voice_ref_path"))
|
||||
updated += 1
|
||||
else:
|
||||
# Create new
|
||||
speaker_map[external_id] = {
|
||||
"external_id": external_id,
|
||||
"display_name": display_name,
|
||||
"role": spk.get("role", "character"),
|
||||
"aliases": spk.get("aliases", []),
|
||||
"voice_ref_path": spk.get("voice_ref_path"),
|
||||
}
|
||||
created += 1
|
||||
|
||||
# Write back
|
||||
if prod_dir:
|
||||
speakers_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with speakers_path.open("w", encoding="utf-8") as fh:
|
||||
json.dump({"speakers": list(speaker_map.values())}, fh, indent=2)
|
||||
|
||||
# Also store in job store for cross-system consistency
|
||||
key = self._job_key(project_id, "speakers", "sync")
|
||||
_JOB_STORE[key] = {
|
||||
"project_id": str(project_id),
|
||||
"speakers": list(speaker_map.values()),
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"aliases_added": aliases_added,
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
self._audit(
|
||||
action=ACTION_SPEAKERS_SYNCED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=project.organization_id,
|
||||
request_id=request_id,
|
||||
detail={
|
||||
"project_slug": project.slug,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"aliases_added": aliases_added,
|
||||
},
|
||||
)
|
||||
return {"created": created, "updated": updated, "aliases_added": aliases_added}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Controller integration: utterance translations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upsert_utterance_translations(
|
||||
self,
|
||||
actor: Principal,
|
||||
project_id: UUID,
|
||||
language_code: str,
|
||||
language_name: str,
|
||||
translations: list[dict],
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> int:
|
||||
"""Upsert utterance translations for one language.
|
||||
|
||||
Each translation dict should carry: utterance_id, translated_text,
|
||||
and optionally speaker_name, status, source_unit_key.
|
||||
"""
|
||||
|
||||
scope = scope_for(actor)
|
||||
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
|
||||
if project is None:
|
||||
if scope.unrestricted:
|
||||
raise NotFoundError("project_not_found")
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
key = self._job_key(project_id, language_code, "translations")
|
||||
existing = _JOB_STORE.get(key, {"translations": []})
|
||||
existing_translations = {t.get("utterance_id"): t for t in existing.get("translations", [])}
|
||||
|
||||
upserted = 0
|
||||
for t in translations:
|
||||
utt_id = str(t.get("utterance_id", ""))
|
||||
if not utt_id:
|
||||
continue
|
||||
existing_translations[utt_id] = {
|
||||
"id": str(uuid4()),
|
||||
"utterance_id": utt_id,
|
||||
"language_code": language_code,
|
||||
"language_name": language_name,
|
||||
"text": t.get("translated_text", ""),
|
||||
"speaker_name": t.get("speaker_name"),
|
||||
"status": t.get("status", "draft"),
|
||||
"origin": t.get("origin", "umbratrans"),
|
||||
"source_ref": t.get("source_ref"),
|
||||
"source_unit_key": t.get("source_unit_key"),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
upserted += 1
|
||||
|
||||
_JOB_STORE[key] = {
|
||||
"project_id": str(project_id),
|
||||
"language_code": language_code,
|
||||
"language_name": language_name,
|
||||
"translations": list(existing_translations.values()),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
self._audit(
|
||||
action=ACTION_TRANSLATIONS_UPSERTED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=project.organization_id,
|
||||
request_id=request_id,
|
||||
detail={
|
||||
"project_slug": project.slug,
|
||||
"language_code": language_code,
|
||||
"upserted": upserted,
|
||||
},
|
||||
)
|
||||
return upserted
|
||||
|
||||
def get_utterance_translations(
|
||||
self,
|
||||
actor: Principal,
|
||||
project_id: UUID,
|
||||
language_code: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Get all utterance translations for one project and language."""
|
||||
|
||||
scope = scope_for(actor)
|
||||
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
|
||||
if project is None:
|
||||
if scope.unrestricted:
|
||||
raise NotFoundError("project_not_found")
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
key = self._job_key(project_id, language_code, "translations")
|
||||
stored = _JOB_STORE.get(key, {"translations": [], "language_name": language_code})
|
||||
|
||||
return {
|
||||
"project_id": str(project_id),
|
||||
"language_code": language_code,
|
||||
"language_name": stored.get("language_name", language_code),
|
||||
"translations": stored.get("translations", []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# End of controller integration methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _project_view(self, project: Project) -> ProjectView:
|
||||
assigned = tuple(self._repository.list_project_member_ids(project.id))
|
||||
return ProjectView(
|
||||
|
||||
Reference in New Issue
Block a user