fix(translate,automation): fix schedule import/param errors, add translation schedules to Automation view
- Fix ModuleNotFoundError: 4 lazy imports in _schedule_routes.py changed from 3-dot to 4-dot relative imports (src.api.dependencies -> src.dependencies) - Fix TypeError: update_schedule() param name mismatch (timezone -> timezone_str) - Add add_translation_job/remove_translation_job to SchedulerService to register translation schedules with APScheduler via execute_scheduled_translation - Add GET /settings/automation/translation-schedules endpoint returning all translation schedules with joined job names - Update Automation settings page to display translation schedules (cron, timezone, active badge, last run) below validation policies
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{#if translationSchedules.length > 0}
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 mt-8">Translation Schedules</h2>
|
||||
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each translationSchedules as ts}
|
||||
<li>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-10 w-10 rounded-full bg-amber-100 dark:bg-amber-900 flex items-center justify-center text-amber-600 dark:text-amber-300">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{ts.job_name}</h3>
|
||||
<div class="mt-1 flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="font-mono">{ts.cron_expression}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{ts.timezone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {ts.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}">
|
||||
{ts.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{#if ts.last_run_at}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last: {new Date(ts.last_run_at).toLocaleString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No translation schedules configured
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion AutomationPage -->
|
||||
Reference in New Issue
Block a user