diff --git a/backend/src/plugins/translate/__tests__/test_orchestrator.py b/backend/src/plugins/translate/__tests__/test_orchestrator.py index ac3addac..12b0f003 100644 --- a/backend/src/plugins/translate/__tests__/test_orchestrator.py +++ b/backend/src/plugins/translate/__tests__/test_orchestrator.py @@ -191,8 +191,30 @@ class TestTranslationOrchestrator: assert run.status == "PENDING" assert run.job_id == "job-123" + # region test_start_run_with_datasource_no_preview_succeeds [TYPE Function] + # @PURPOSE: Manual run with Superset datasource succeeds even without accepted preview session. + def test_start_run_with_datasource_no_preview_succeeds( + self, + mock_job: MagicMock, + ) -> None: + db = MagicMock() + config_manager = MagicMock() + + # Mock job query — job HAS a Superset datasource (fixture sets source_datasource_id="42") + db.query.return_value.filter.return_value.first.return_value = mock_job + + # No accepted preview session mock — not needed because datasource bypasses preview check. + # validate_job_preconditions skips preview query when source_datasource_id is set. + # This verifies the gating logic: datasource → no preview required → run proceeds. + + orch = TranslationOrchestrator(db, config_manager, "test-user") + run = orch.start_run(job_id="job-123") + + assert run.status == "PENDING" + assert run.job_id == "job-123" + # region test_start_run_missing_preview_raises [TYPE Function] - # @PURPOSE: Manual run without accepted preview raises ValueError. + # @PURPOSE: Manual run without accepted preview raises ValueError when job has no direct datasource. def test_start_run_missing_preview_raises( self, mock_job: MagicMock, @@ -200,7 +222,9 @@ class TestTranslationOrchestrator: db = MagicMock() config_manager = MagicMock() - # Mock job query + # Mock job query — job WITHOUT a Superset datasource, so preview is required + mock_job.source_datasource_id = None + mock_job.environment_id = None db.query.return_value.filter.return_value.first.return_value = mock_job # No accepted preview session diff --git a/backend/src/plugins/translate/orchestrator_validation.py b/backend/src/plugins/translate/orchestrator_validation.py index 6f275bd8..9f859115 100644 --- a/backend/src/plugins/translate/orchestrator_validation.py +++ b/backend/src/plugins/translate/orchestrator_validation.py @@ -39,19 +39,23 @@ def validate_job_preconditions( "Select a translation column before running." ) if not is_scheduled: - from ...models.translate import TranslationPreviewSession - accepted_session = ( - db_session.query(TranslationPreviewSession) - .filter( - TranslationPreviewSession.job_id == job.id, - TranslationPreviewSession.status == "APPLIED", - ) - .order_by(TranslationPreviewSession.created_at.desc()) - .first() - ) - if not accepted_session: - raise ValueError( - f"Job '{job.id}' has no accepted preview session. " - "Run and accept a preview before executing a manual translation run." + # Jobs with a direct Superset datasource can fetch rows from Superset directly + # (see RunSourceFetcher.fetch_source_rows), so they don't need a preview session. + # Only require an accepted preview for jobs without a configured datasource. + if not bool(job.source_datasource_id): + from ...models.translate import TranslationPreviewSession + accepted_session = ( + db_session.query(TranslationPreviewSession) + .filter( + TranslationPreviewSession.job_id == job.id, + TranslationPreviewSession.status == "APPLIED", + ) + .order_by(TranslationPreviewSession.created_at.desc()) + .first() ) + if not accepted_session: + raise ValueError( + f"Job '{job.id}' has no accepted preview session. " + "Run and accept a preview before executing a manual translation run." + ) # #endregion validate_job_preconditions diff --git a/frontend/src/lib/api/translate/schedules.js b/frontend/src/lib/api/translate/schedules.js index 8f9154fa..60d645b5 100644 --- a/frontend/src/lib/api/translate/schedules.js +++ b/frontend/src/lib/api/translate/schedules.js @@ -13,7 +13,7 @@ function normalizeTranslateError(error, defaultMessage = 'Translation API error' export async function fetchSchedule(jobId) { try { - return await api.fetchApi(`/translate/jobs/${jobId}/schedule`); + return await api.fetchApi(`/translate/jobs/${jobId}/schedule`, { suppressToast: true }); } catch (error) { throw normalizeTranslateError(error, 'Failed to fetch schedule'); } @@ -53,7 +53,7 @@ export async function disableSchedule(jobId) { export async function fetchNextExecutions(jobId, n = 3) { try { - return await api.fetchApi(`/translate/jobs/${jobId}/schedule/next-executions?n=${n}`); + return await api.fetchApi(`/translate/jobs/${jobId}/schedule/next-executions?n=${n}`, { suppressToast: true }); } catch (error) { throw normalizeTranslateError(error, 'Failed to fetch next executions'); } diff --git a/frontend/src/routes/translate/[id]/+page.svelte b/frontend/src/routes/translate/[id]/+page.svelte index e9fabb82..b73c26b7 100644 --- a/frontend/src/routes/translate/[id]/+page.svelte +++ b/frontend/src/routes/translate/[id]/+page.svelte @@ -232,9 +232,9 @@ } catch (_e) {} await loadInitialData(); - if (!isNewJob) { - await loadJob(); - } else { + + // Handle new-job initialisation (runs once on mount) + if (isNewJob) { if (environmentId) { await loadDatabases(); } @@ -242,6 +242,16 @@ } }); + // Watch for jobId changes — handles: + // 1. Direct navigation to existing job page (environments loaded in onMount) + // 2. Navigation after createJob (goto → param change, component reused) + // Guards: only runs when environments are ready and it's an existing job + $effect(() => { + if (jobId && !isNewJob && environments.length > 0) { + loadJob(); + } + }); + // Clean up store callback when page unmounts — the store keeps // polling so the global indicator in the layout still works. onDestroy(() => { @@ -587,6 +597,7 @@ try { if (isNewJob) { const created = await createJob(payload); + existingJob = created; addToast(_('translate.config.job_created'), 'success'); goto(`/translate/${created.id}`); } else { @@ -1225,12 +1236,14 @@ - + + {#if !isNewJob}
+ {/if}