refactor(backend): split translate run routes into edit/history modules

Extract inline edit, bulk find-replace, and override language endpoints from
_run_routes.py into dedicated _run_edit_routes.py and _run_history_routes.py
modules to reduce module complexity below INV_7 limits.

Changes:
- _run_routes.py now only handles execution, retry, and cancel endpoints
- _run_edit_routes.py (new): inline edit, bulk find-replace, override language
- __init__.py registers the new route modules
- schemas/translate.py: add imports for extracted endpoints
This commit is contained in:
2026-06-04 16:15:48 +03:00
parent ee5ebf08de
commit b823bc559b
4 changed files with 219 additions and 323 deletions

View File

@@ -27,8 +27,10 @@ from . import (
_job_routes, # noqa: F401 — registers job handlers
_metrics_routes, # noqa: F401 — registers metrics handlers
_preview_routes, # noqa: F401 — registers preview handlers
_run_edit_routes, # noqa: F401 — registers run edit/correction/bulk-replace handlers
_run_history_routes, # noqa: F401 — registers run history/status/records/batches handlers
_run_list_routes, # noqa: F401 — registers run list/detail/csv handlers
_run_routes, # noqa: F401 — registers run handlers
_run_routes, # noqa: F401 — registers run execution/retry/cancel handlers
_schedule_routes, # noqa: F401 — registers schedule handlers
)
from ._router import router

View File

@@ -0,0 +1,200 @@
# #region TranslateRunEditRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, edit, correction, bulk, override]
# @BRIEF Translation Run inline edit, bulk find-replace, and language override routes.
# @LAYER API
# @RELATION DEPENDS_ON -> [TranslateRunRoutesModule]
# @RELATION CALLED_BY -> [TranslateRoutes]
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from ....core.config_manager import ConfigManager
from ....core.database import get_db
from ....core.logger import logger
from ....dependencies import get_config_manager, get_current_user, has_permission
from ....schemas.auth import User
from ....schemas.translate import (
BulkFindReplaceRequest,
InlineEditRequest,
OverrideLanguageRequest,
)
from ._router import router
# #region override_detected_language [C:4] [TYPE Function]
# @BRIEF Manually override the detected source language for a specific translation language entry.
# @PRE User has translate.job.execute permission. Run, record, and language entry exist.
# @POST TranslationLanguage.source_language_detected is updated; language_overridden is set to True.
# @SIDE_EFFECT DB write.
@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}/override-language")
def override_detected_language(
run_id: str,
record_id: str,
language_code: str,
payload: OverrideLanguageRequest,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
):
logger.reason(
f"override_detected_language — Run: {run_id}, Record: {record_id}, "
f"Lang: {language_code}, Override: {payload.source_language}",
extra={"src": "translate_routes"},
)
try:
from ....models.translate import TranslationLanguage, TranslationRecord
record = (
db.query(TranslationRecord)
.filter(
TranslationRecord.id == record_id,
TranslationRecord.run_id == run_id,
)
.first()
)
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Translation record '{record_id}' not found in run '{run_id}'",
)
lang_entry = (
db.query(TranslationLanguage)
.filter(
TranslationLanguage.record_id == record_id,
TranslationLanguage.language_code == language_code,
)
.first()
)
if not lang_entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Language entry '{language_code}' not found for record '{record_id}'",
)
lang_entry.source_language_detected = payload.source_language
lang_entry.language_overridden = True
lang_entry.needs_review = False
db.commit()
db.refresh(lang_entry)
logger.reason("Language override applied", {
"run_id": run_id,
"record_id": record_id,
"language_code": language_code,
"new_source_language": payload.source_language,
"overridden_by": current_user.username,
})
from ....schemas.translate import TranslationLanguageResponse
return TranslationLanguageResponse.model_validate(lang_entry)
except HTTPException:
raise
except Exception as e:
logger.explore("override_detected_language failed", extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion override_detected_language
# #region inline_edit_translation [C:4] [TYPE Function] [SEMANTICS api,translate,correction]
# @BRIEF Apply an inline correction to a translated value on a completed run result.
# @PRE User has translate.job.execute permission. Run, record, and language entry exist.
# @POST TranslationLanguage.final_value and user_edit are updated. Optional dictionary submission.
@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}")
def inline_edit_translation(
run_id: str,
record_id: str,
language_code: str,
payload: InlineEditRequest,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
):
logger.reason(
f"inline_edit_translation — Run: {run_id}, Record: {record_id}, "
f"Lang: {language_code}, User: {current_user.username}",
extra={"src": "translate_routes"},
)
try:
from ....plugins.translate.service import InlineCorrectionService
result = InlineCorrectionService.apply_inline_edit(
db=db,
run_id=run_id,
record_id=record_id,
language_code=language_code,
final_value=payload.final_value,
submit_to_dictionary=payload.submit_to_dictionary,
dictionary_id=payload.dictionary_id,
current_user=current_user.username,
context_data_override=payload.context_data_override,
usage_notes=payload.usage_notes,
keep_context=payload.keep_context,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.explore("inline_edit_translation failed", extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion inline_edit_translation
# #region bulk_find_replace [C:4] [TYPE Function] [SEMANTICS api,translate,bulk,replace]
# @BRIEF Perform bulk find-and-replace on translated values within a run.
# @PRE User has translate.job.execute permission. Run exists.
# @POST If preview=false, matching translations are updated. Optional dictionary submission.
@router.post("/runs/{run_id}/bulk-replace")
def bulk_find_replace(
run_id: str,
payload: BulkFindReplaceRequest,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
):
logger.reason(
f"bulk_find_replace — Run: {run_id}, Pattern: '{payload.find_pattern}', "
f"Regex: {payload.is_regex}, Lang: {payload.target_language}, "
f"User: {current_user.username}",
extra={"src": "translate_routes"},
)
try:
from ....plugins.translate.service import BulkFindReplaceService
if payload.preview:
preview = BulkFindReplaceService.preview(
db=db,
run_id=run_id,
pattern=payload.find_pattern,
is_regex=payload.is_regex,
replacement_text=payload.replacement_text,
target_language=payload.target_language,
)
return {
"rows_affected": len(preview),
"corrections_submitted": 0,
"preview": preview,
}
result = BulkFindReplaceService.apply(
db=db,
run_id=run_id,
pattern=payload.find_pattern,
is_regex=payload.is_regex,
replacement_text=payload.replacement_text,
target_language=payload.target_language,
submit_to_dictionary=payload.submit_to_dictionary,
dictionary_id=payload.dictionary_id,
usage_notes=payload.usage_notes,
current_user=current_user.username,
submit_to_dictionary_with_context=payload.submit_to_dictionary_with_context,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.explore("bulk_find_replace failed", extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion bulk_find_replace
# #endregion TranslateRunEditRoutesModule

View File

@@ -1,6 +1,9 @@
# #region TranslateRunRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, search, execution, history]
# @BRIEF Translation Run execution, history, status, records and batches routes.
# #region TranslateRunRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, execution]
# @BRIEF Translation Run execution, retry, and cancel routes.
# @LAYER API
# @RELATION DEPENDS_ON -> [TranslateRunHistoryRoutesModule]
# @RELATION DEPENDS_ON -> [TranslateRunEditRoutesModule]
# @RELATION CALLED_BY -> [TranslateRoutes]
from datetime import UTC, datetime
@@ -8,22 +11,15 @@ from fastapi import Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ....core.config_manager import ConfigManager
from ....core.cot_logger import get_trace_id, seed_trace_id, set_trace_id
from ....core.database import SessionLocal, get_db
from ....core.logger import logger
from ....dependencies import get_config_manager, get_current_user, has_permission
from ....plugins.translate.orchestrator import TranslationOrchestrator
from ....schemas.auth import User
from ....schemas.translate import (
BulkFindReplaceRequest,
InlineEditRequest,
OverrideLanguageRequest,
)
from ._helpers import _run_to_response
from ._router import router
# ============================================================
# Translation Run / Execute
# ============================================================
# #region run_translation [C:4] [TYPE Function]
# @BRIEF Execute a translation job (trigger a run).
@@ -55,8 +51,16 @@ def run_translation(
# closed or expunged session.
import threading
# Capture request trace_id for background thread correlation
_request_trace_id = get_trace_id()
def _background_execute():
from ....models.translate import TranslationRun as TRModel
# Preserve request's trace_id for log correlation across the thread boundary
if _request_trace_id:
set_trace_id(_request_trace_id)
else:
seed_trace_id()
bg_db = None
try:
bg_db = SessionLocal()
@@ -239,316 +243,4 @@ def cancel_run(
# #endregion cancel_run
# #region get_run_history [C:4] [TYPE Function]
# @BRIEF Get run history for a translation job.
# @PRE User has translate.history.view permission.
# @POST Returns list of runs.
@router.get("/jobs/{job_id}/runs")
def get_run_history(
job_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get run history for a translation job."""
logger.reason(f"get_run_history — Job: {job_id}, User: {current_user.username}", extra={"src": "translate_routes"})
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
total, runs = orch.get_run_history(job_id, page=page, page_size=page_size)
return {"items": runs, "total": total, "page": page, "page_size": page_size}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion get_run_history
# #region get_run_status [C:4] [TYPE Function]
# @BRIEF Get status and statistics for a translation run.
# @PRE User has translate.history.view permission.
# @POST Returns run details with statistics.
@router.get("/runs/{run_id}")
def get_run_status(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get status and statistics for a translation run."""
logger.reason(f"get_run_status — Run: {run_id}, User: {current_user.username}", extra={"src": "translate_routes"})
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
return orch.get_run_status(run_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion get_run_status
# #region get_run_records [C:4] [TYPE Function]
# @BRIEF Get paginated records for a translation run.
# @PRE User has translate.history.view permission.
# @POST Returns paginated records.
@router.get("/runs/{run_id}/records")
def get_run_records(
run_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
status: str | None = Query(None),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get paginated records for a translation run."""
logger.reason(f"get_run_records — Run: {run_id}, User: {current_user.username}", extra={"src": "translate_routes"})
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
return orch.get_run_records(run_id, page=page, page_size=page_size, status_filter=status)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion get_run_records
# ============================================================
# Batches
# ============================================================
# #region get_batches [C:4] [TYPE Function]
# @BRIEF Get batches for a translation run.
# @PRE User has translate.job.view permission.
# @POST Returns list of batches.
@router.get("/runs/{run_id}/batches")
def get_batches(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
):
"""Get batches for a translation run."""
logger.reason(f"get_batches — Run: {run_id}, User: {current_user.username}", extra={"src": "translate_routes"})
try:
from ....models.translate import TranslationBatch
batches = (
db.query(TranslationBatch)
.filter(TranslationBatch.run_id == run_id)
.order_by(TranslationBatch.batch_index.asc())
.all()
)
return [
{
"id": b.id,
"run_id": b.run_id,
"batch_index": b.batch_index,
"status": b.status,
"total_records": b.total_records or 0,
"successful_records": b.successful_records or 0,
"failed_records": b.failed_records or 0,
"started_at": b.started_at.isoformat() if b.started_at else None,
"completed_at": b.completed_at.isoformat() if b.completed_at else None,
"created_at": b.created_at.isoformat() if b.created_at else None,
}
for b in batches
]
except Exception as e:
logger.explore("get_batches failed", extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion get_batches
# ============================================================
# Manual Language Override
# ============================================================
# #region override_detected_language [C:4] [TYPE Function]
# @BRIEF Manually override the detected source language for a specific translation language entry.
# @PRE User has translate.job.execute permission. Run, record, and language entry exist.
# @POST TranslationLanguage.source_language_detected is updated; language_overridden is set to True.
# @SIDE_EFFECT DB write.
@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}/override-language")
def override_detected_language(
run_id: str,
record_id: str,
language_code: str,
payload: OverrideLanguageRequest,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
):
"""Manually override the detected source language for a translation entry."""
logger.reason(f"override_detected_language — Run: {run_id}, Record: {record_id}, Lang: {language_code}, Override: {payload.source_language}", extra={"src": "translate_routes"})
try:
from ....models.translate import TranslationLanguage, TranslationRecord
# Verify the record exists and belongs to the run
record = (
db.query(TranslationRecord)
.filter(
TranslationRecord.id == record_id,
TranslationRecord.run_id == run_id,
)
.first()
)
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Translation record '{record_id}' not found in run '{run_id}'",
)
# Find the language entry
lang_entry = (
db.query(TranslationLanguage)
.filter(
TranslationLanguage.record_id == record_id,
TranslationLanguage.language_code == language_code,
)
.first()
)
if not lang_entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Language entry '{language_code}' not found for record '{record_id}'",
)
# Update the language entry
lang_entry.source_language_detected = payload.source_language
lang_entry.language_overridden = True
lang_entry.needs_review = False # Override clears the review flag
db.commit()
db.refresh(lang_entry)
logger.reason("Language override applied", {
"run_id": run_id,
"record_id": record_id,
"language_code": language_code,
"new_source_language": payload.source_language,
"overridden_by": current_user.username,
})
from ....schemas.translate import TranslationLanguageResponse
return TranslationLanguageResponse.model_validate(lang_entry)
except HTTPException:
raise
except Exception as e:
logger.explore("override_detected_language failed", extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion override_detected_language
# ============================================================
# Inline Correction
# ============================================================
# #region inline_edit_translation [C:4] [TYPE Function] [SEMANTICS api,translate,correction]
# @BRIEF Apply an inline correction to a translated value on a completed run result.
# @PRE User has translate.job.execute permission. Run, record, and language entry exist.
# @POST TranslationLanguage.final_value and user_edit are updated. Optional dictionary submission.
@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}")
def inline_edit_translation(
run_id: str,
record_id: str,
language_code: str,
payload: InlineEditRequest,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
):
"""Apply an inline correction to a translated value."""
logger.reason(
f"inline_edit_translation — Run: {run_id}, Record: {record_id}, "
f"Lang: {language_code}, User: {current_user.username}",
extra={"src": "translate_routes"},
)
try:
from ....plugins.translate.service import InlineCorrectionService
result = InlineCorrectionService.apply_inline_edit(
db=db,
run_id=run_id,
record_id=record_id,
language_code=language_code,
final_value=payload.final_value,
submit_to_dictionary=payload.submit_to_dictionary,
dictionary_id=payload.dictionary_id,
current_user=current_user.username,
context_data_override=payload.context_data_override,
usage_notes=payload.usage_notes,
keep_context=payload.keep_context,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.explore("inline_edit_translation failed", extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion inline_edit_translation
# ============================================================
# Bulk Find-and-Replace
# ============================================================
# #region bulk_find_replace [C:4] [TYPE Function] [SEMANTICS api,translate,bulk,replace]
# @BRIEF Perform bulk find-and-replace on translated values within a run.
# @PRE User has translate.job.execute permission. Run exists.
# @POST If preview=false, matching translations are updated. Optional dictionary submission.
# @COMPLEXITY 4
@router.post("/runs/{run_id}/bulk-replace")
def bulk_find_replace(
run_id: str,
payload: BulkFindReplaceRequest,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
):
"""Perform bulk find-and-replace on translated values within a run."""
logger.reason(
f"bulk_find_replace — Run: {run_id}, Pattern: '{payload.find_pattern}', "
f"Regex: {payload.is_regex}, Lang: {payload.target_language}, "
f"User: {current_user.username}",
extra={"src": "translate_routes"},
)
try:
from ....plugins.translate.service import BulkFindReplaceService
if payload.preview:
# Preview mode: return matches without applying
preview = BulkFindReplaceService.preview(
db=db,
run_id=run_id,
pattern=payload.find_pattern,
is_regex=payload.is_regex,
replacement_text=payload.replacement_text,
target_language=payload.target_language,
)
return {
"rows_affected": len(preview),
"corrections_submitted": 0,
"preview": preview,
}
# Apply mode: perform replacements
result = BulkFindReplaceService.apply(
db=db,
run_id=run_id,
pattern=payload.find_pattern,
is_regex=payload.is_regex,
replacement_text=payload.replacement_text,
target_language=payload.target_language,
submit_to_dictionary=payload.submit_to_dictionary,
dictionary_id=payload.dictionary_id,
usage_notes=payload.usage_notes,
current_user=current_user.username,
submit_to_dictionary_with_context=payload.submit_to_dictionary_with_context,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.explore("bulk_find_replace failed", extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion bulk_find_replace
# #endregion TranslateRunRoutesModule

View File

@@ -213,6 +213,7 @@ class DictionaryEntryCreate(BaseModel):
context_notes: str | None = Field(None, description="Optional context notes")
source_language: str = Field("und", description="BCP-47 source language code (default: und)")
target_language: str = Field("und", description="BCP-47 target language code (default: und)")
is_regex: bool = Field(False, description="Whether source_term is a regex pattern")
model_config = ConfigDict(from_attributes=True)
# #endregion DictionaryEntryCreate
@@ -232,6 +233,7 @@ class DictionaryEntryResponse(BaseModel):
context_data: dict[str, Any] | None = None
usage_notes: str | None = None
has_context: bool = False
is_regex: bool = False
context_source: str | None = None
origin_source_language: str | None = None
created_at: datetime