diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index 94a24a7c..08f31771 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -25,7 +25,9 @@ from ...schemas.settings import ( ValidationPolicyCreate, ValidationPolicyUpdate, ValidationPolicyResponse, + TranslationScheduleItem, ) +from ...models.translate import TranslationSchedule, TranslationJob from ...core.database import get_db from sqlalchemy.orm import Session @@ -598,4 +600,40 @@ async def delete_validation_policy( # #endregion delete_validation_policy + +# #region get_translation_schedules [C:2] [TYPE Function] +# @BRIEF Lists all translation schedules joined with translation job names. +@router.get("/automation/translation-schedules", response_model=List[TranslationScheduleItem]) +async def get_translation_schedules( + db: Session = Depends(get_db), + _=Depends(has_permission("admin:settings", "READ")), +): + with belief_scope("get_translation_schedules"): + results = ( + db.query( + TranslationSchedule, + TranslationJob.name.label("job_name"), + ) + .outerjoin(TranslationJob, TranslationSchedule.job_id == TranslationJob.id) + .all() + ) + return [ + TranslationScheduleItem( + schedule_id=ts.id, + job_id=ts.job_id, + job_name=job_name, + cron_expression=ts.cron_expression, + timezone=ts.timezone, + is_active=ts.is_active, + last_run_at=ts.last_run_at, + next_run_at=ts.next_run_at, + created_by=ts.created_by, + created_at=ts.created_at, + ) + for ts, job_name in results + ] + + +# #endregion get_translation_schedules + # #endregion SettingsRouter diff --git a/backend/src/api/routes/translate/_schedule_routes.py b/backend/src/api/routes/translate/_schedule_routes.py index f2887371..a6a6eba1 100644 --- a/backend/src/api/routes/translate/_schedule_routes.py +++ b/backend/src/api/routes/translate/_schedule_routes.py @@ -81,7 +81,7 @@ async def set_schedule( schedule = scheduler.update_schedule( job_id, cron_expression=payload.cron_expression, - timezone=payload.timezone, + timezone_str=payload.timezone, is_active=payload.is_active, ) else: @@ -92,7 +92,7 @@ async def set_schedule( is_active=payload.is_active, ) # Register with APScheduler via SchedulerService - from ...dependencies import get_scheduler_service + from ....dependencies import get_scheduler_service sched_svc = get_scheduler_service() sched_svc.add_translation_job( schedule_id=schedule.id, @@ -133,7 +133,7 @@ async def enable_schedule( try: scheduler = TranslationScheduler(db, config_manager, current_user.username) schedule = scheduler.set_schedule_active(job_id, is_active=True) - from ...dependencies import get_scheduler_service + from ....dependencies import get_scheduler_service sched_svc = get_scheduler_service() sched_svc.add_translation_job( schedule_id=schedule.id, @@ -164,7 +164,7 @@ async def disable_schedule( try: scheduler = TranslationScheduler(db, config_manager, current_user.username) scheduler.set_schedule_active(job_id, is_active=False) - from ...dependencies import get_scheduler_service + from ....dependencies import get_scheduler_service sched_svc = get_scheduler_service() sched_svc.remove_translation_job(schedule_id=job_id) return {"status": "disabled", "job_id": job_id} @@ -190,7 +190,7 @@ async def delete_schedule( try: scheduler = TranslationScheduler(db, config_manager, current_user.username) scheduler.delete_schedule(job_id) - from ...dependencies import get_scheduler_service + from ....dependencies import get_scheduler_service sched_svc = get_scheduler_service() sched_svc.remove_translation_job(schedule_id=job_id) return None diff --git a/backend/src/core/scheduler.py b/backend/src/core/scheduler.py index a49abb17..a6976f95 100644 --- a/backend/src/core/scheduler.py +++ b/backend/src/core/scheduler.py @@ -6,6 +6,7 @@ from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from .logger import logger, belief_scope from .config_manager import ConfigManager +from .database import SessionLocal import asyncio from datetime import datetime, time, timedelta, date # #region SchedulerService [C:3] [TYPE Class] [SEMANTICS scheduler, service, apscheduler] @@ -83,6 +84,57 @@ class SchedulerService: except Exception as e: logger.error(f"Failed to add backup job for environment {env_id}: {e}") # #endregion add_backup_job + # #region add_translation_job [C:4] [TYPE Function] [SEMANTICS scheduler,translation,cron,apscheduler] + # @BRIEF Register a translation schedule with APScheduler. + # @PRE schedule_id, job_id, and cron_expression are valid strings. + # @POST A new APScheduler job is registered or replaced if it already exists. + # @SIDE_EFFECT Mutates APScheduler state; calls execute_scheduled_translation on trigger. + # @RELATION DEPENDS_ON -> [execute_scheduled_translation] + # @RELATION DEPENDS_ON -> [SessionLocal] + def add_translation_job(self, schedule_id: str, job_id: str, cron_expression: str, timezone: str = "UTC"): + with belief_scope( + "SchedulerService.add_translation_job", + f"schedule_id={schedule_id}, job_id={job_id}, cron={cron_expression}", + ): + from zoneinfo import ZoneInfo + from ..plugins.translate.scheduler import execute_scheduled_translation + + job_id_aps = f"translate_{schedule_id}" + try: + tz = ZoneInfo(timezone) + self.scheduler.add_job( + execute_scheduled_translation, + CronTrigger.from_crontab(cron_expression, timezone=tz), + id=job_id_aps, + args=[schedule_id, job_id, SessionLocal, self.config_manager], + replace_existing=True, + ) + logger.reason( + f"Translation schedule registered: {job_id_aps} ({cron_expression})" + ) + except Exception as e: + logger.explore( + f"Failed to register translation schedule: {job_id_aps}", + extra={"error": str(e)}, + ) + # #endregion add_translation_job + # #region remove_translation_job [C:4] [TYPE Function] [SEMANTICS scheduler,translation,remove,apscheduler] + # @BRIEF Remove a translation schedule from APScheduler. + # @PRE schedule_id is a valid string. + # @POST The APScheduler job is removed if it exists; silently ignored otherwise. + # @SIDE_EFFECT Mutates APScheduler state. + def remove_translation_job(self, schedule_id: str): + with belief_scope( + "SchedulerService.remove_translation_job", + f"schedule_id={schedule_id}", + ): + job_id_aps = f"translate_{schedule_id}" + try: + self.scheduler.remove_job(job_id_aps) + logger.info(f"Translation schedule removed: {job_id_aps}") + except Exception: + logger.reason(f"Translation schedule not found (already removed): {job_id_aps}") + # #endregion remove_translation_job # #region _trigger_backup [TYPE Function] # @PURPOSE: Triggered by the scheduler to start a backup task. # @PRE: env_id must be a valid environment ID. diff --git a/backend/src/schemas/settings.py b/backend/src/schemas/settings.py index 8cf482f6..51de98bb 100644 --- a/backend/src/schemas/settings.py +++ b/backend/src/schemas/settings.py @@ -92,4 +92,25 @@ class ValidationPolicyResponse(ValidationPolicyBase): # #endregion ValidationPolicyResponse + +# #region TranslationScheduleItem [C:1] [TYPE Class] +# @BRIEF Response schema for a translation schedule item with joined job name. +class TranslationScheduleItem(BaseModel): + schedule_id: str + job_id: str + job_name: Optional[str] = None + cron_expression: str + timezone: str + is_active: bool + last_run_at: Optional[datetime] = None + next_run_at: Optional[datetime] = None + created_by: Optional[str] = None + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# #endregion TranslationScheduleItem + # #endregion SettingsSchemas diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 7632b3f2..2520e526 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -418,6 +418,7 @@ export const api = { createValidationPolicy: (policy) => postApi('/settings/automation/policies', policy), updateValidationPolicy: (id, policy) => requestApi(`/settings/automation/policies/${id}`, 'PATCH', policy), deleteValidationPolicy: (id) => requestApi(`/settings/automation/policies/${id}`, 'DELETE'), + getTranslationSchedules: () => fetchApi('/settings/automation/translation-schedules'), // Health getHealthSummary: (environmentId) => { @@ -458,4 +459,5 @@ export const getValidationPolicies = api.getValidationPolicies; export const createValidationPolicy = api.createValidationPolicy; export const updateValidationPolicy = api.updateValidationPolicy; export const deleteValidationPolicy = api.deleteValidationPolicy; +export const getTranslationSchedules = api.getTranslationSchedules; export const getHealthSummary = api.getHealthSummary; diff --git a/frontend/src/routes/settings/automation/+page.svelte b/frontend/src/routes/settings/automation/+page.svelte index 752dfb31..27b9c1c5 100644 --- a/frontend/src/routes/settings/automation/+page.svelte +++ b/frontend/src/routes/settings/automation/+page.svelte @@ -18,12 +18,14 @@ createValidationPolicy, updateValidationPolicy, deleteValidationPolicy, - getEnvironments + getEnvironments, + getTranslationSchedules } from '$lib/api'; import { addToast } from '$lib/toasts'; let policies = $state([]); let environments = $state([]); + let translationSchedules = $state([]); let isLoading = $state(true); let showForm = $state(false); let selectedPolicy = $state(null); @@ -35,9 +37,10 @@ async function loadData() { isLoading = true; try { - const [policiesData, envsData] = await Promise.all([ + const [policiesData, envsData, schedulesData] = await Promise.all([ getValidationPolicies(), - getEnvironments() + getEnvironments(), + getTranslationSchedules() ]); const policyArray = Array.isArray(policiesData) ? policiesData : []; @@ -82,6 +85,7 @@ policies = policiesData; environments = envsData; + translationSchedules = Array.isArray(schedulesData) ? schedulesData : []; } catch (error) { console.error('Failed to load automation data:', error); } finally { @@ -280,6 +284,53 @@ {/each} + + {#if translationSchedules.length > 0} +

Translation Schedules

+
+ +
+ {/if} {/if} \ No newline at end of file