Merge pull request #576 from christianlouis/copilot/add-scheduled-batch-processing

feat: scheduled batch processing — 8 admin-managed jobs, DB-driven beat schedule, 100% coverage
This commit is contained in:
Christian Krakau-Louis
2026-03-09 22:30:20 +01:00
committed by GitHub
14 changed files with 3681 additions and 0 deletions
+2
View File
@@ -29,6 +29,7 @@ from app.api.plans import router as plans_router
from app.api.process import router as process_router
from app.api.queue import router as queue_router
from app.api.saved_searches import router as saved_searches_router
from app.api.scheduled_jobs import router as scheduled_jobs_router
from app.api.search import router as search_router
from app.api.settings import router as settings_router
from app.api.shared_links import public_router as shared_links_public_router
@@ -80,3 +81,4 @@ router.include_router(pipelines_router)
router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
+350
View File
@@ -0,0 +1,350 @@
"""
Admin API endpoints for managing scheduled batch processing jobs.
All endpoints require admin privileges (checked via session ``is_admin`` flag).
Available routes:
GET /api/admin/scheduled-jobs list all scheduled jobs
PATCH /api/admin/scheduled-jobs/{id} update schedule / enable-disable
POST /api/admin/scheduled-jobs/{id}/run-now trigger a job immediately
"""
import logging
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import ScheduledJob
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/scheduled-jobs", tags=["admin-scheduled-jobs"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Authorisation helper
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin; raises HTTP 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ScheduledJobResponse(BaseModel):
"""Read model for a scheduled job."""
id: int
name: str
display_name: str
description: str | None
task_name: str
enabled: bool
schedule_type: str
cron_minute: str
cron_hour: str
cron_day_of_week: str
cron_day_of_month: str
cron_month_of_year: str
interval_seconds: int | None
last_run_at: datetime | None
last_run_status: str | None
last_run_detail: str | None
created_at: datetime | None
updated_at: datetime | None
model_config = {"from_attributes": True}
class ScheduledJobUpdate(BaseModel):
"""Writable fields for a scheduled job update (all optional)."""
enabled: bool | None = Field(None, description="Whether the job is active")
schedule_type: str | None = Field(None, pattern="^(cron|interval)$", description="'cron' or 'interval'")
cron_minute: str | None = Field(None, max_length=50)
cron_hour: str | None = Field(None, max_length=50)
cron_day_of_week: str | None = Field(None, max_length=50)
cron_day_of_month: str | None = Field(None, max_length=50)
cron_month_of_year: str | None = Field(None, max_length=50)
interval_seconds: int | None = Field(None, ge=60, description="Interval in seconds (min 60)")
# ---------------------------------------------------------------------------
# Default job definitions seeded into the DB on first startup
# ---------------------------------------------------------------------------
DEFAULT_JOBS: list[dict[str, Any]] = [
{
"name": "process-new-documents",
"display_name": "Process New Documents",
"description": (
"Scans for documents that have been uploaded but never processed "
"and queues them through the full processing pipeline. "
"Runs hourly by default."
),
"task_name": "app.tasks.batch_tasks.process_new_documents",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "*/1",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "reprocess-failed-documents",
"display_name": "Reprocess Failed Documents",
"description": (
"Finds documents whose last processing attempt failed and re-queues "
"them for reprocessing. Only picks up files that are not currently "
"being processed. Runs every 6 hours by default."
),
"task_name": "app.tasks.batch_tasks.reprocess_failed_documents",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "30",
"cron_hour": "*/6",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "cleanup-temp-files",
"display_name": "Clean Up Temporary Files",
"description": (
"Removes stale files from the workdir/tmp directory. "
"Only files older than 24 hours that are not referenced by any active "
"processing job are deleted. Runs daily at 03:30 UTC by default."
),
"task_name": "app.tasks.batch_tasks.cleanup_temp_files",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "30",
"cron_hour": "3",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "expire-shared-links",
"display_name": "Expire Stale Shared Links",
"description": (
"Marks shared document links as inactive when their expiry time has passed. "
"Access is already blocked at request time, but this task keeps the "
"management UI counts accurate. Runs daily at 01:00 UTC by default."
),
"task_name": "app.tasks.batch_tasks.expire_shared_links",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "1",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "prune-processing-logs",
"display_name": "Prune Old Processing Logs",
"description": (
"Deletes processing log entries and settings audit log entries older than "
"30 days to prevent unbounded database growth. "
"Runs weekly on Sunday at 04:00 UTC by default."
),
"task_name": "app.tasks.batch_tasks.prune_processing_logs",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "4",
"cron_day_of_week": "0",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "prune-old-notifications",
"display_name": "Prune Old Notifications",
"description": (
"Deletes read in-app notifications older than 30 days. "
"Unread notifications are never deleted. "
"Runs weekly on Sunday at 04:30 UTC by default."
),
"task_name": "app.tasks.batch_tasks.prune_old_notifications",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "30",
"cron_hour": "4",
"cron_day_of_week": "0",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "backfill-missing-metadata",
"display_name": "Backfill Missing AI Metadata",
"description": (
"Re-triggers AI metadata extraction for documents that have extracted "
"text but no AI metadata yet (e.g., processed before an AI provider "
"was configured). Processes up to 50 documents per run. "
"Runs every 6 hours by default."
),
"task_name": "app.tasks.batch_tasks.backfill_missing_metadata",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "*/6",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "sync-search-index",
"display_name": "Sync Search Index",
"description": (
"Indexes documents that have OCR text or AI metadata but are missing "
"from the Meilisearch search index. Useful after enabling search on "
"an existing installation or after an index rebuild. "
"Processes up to 100 documents per run. "
"Runs hourly by default."
),
"task_name": "app.tasks.batch_tasks.sync_search_index",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "15",
"cron_hour": "*/1",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
]
def seed_default_scheduled_jobs(db: Session) -> None:
"""
Insert the built-in scheduled jobs if they do not already exist.
Called from the FastAPI lifespan handler so the records are available
immediately after the first startup.
"""
for job_data in DEFAULT_JOBS:
existing = db.query(ScheduledJob).filter(ScheduledJob.name == job_data["name"]).first()
if existing is None:
db.add(ScheduledJob(**job_data))
try:
db.commit()
except Exception as exc:
db.rollback()
logger.error("Failed to seed default scheduled jobs: %s", exc)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=list[ScheduledJobResponse])
def list_scheduled_jobs(request: Request, db: DbSession, _admin: AdminUser) -> list[ScheduledJobResponse]:
"""
Return all scheduled jobs ordered by display name.
Requires admin privileges.
"""
jobs = db.query(ScheduledJob).order_by(ScheduledJob.display_name).all()
return jobs # type: ignore[return-value]
@router.patch("/{job_id}", response_model=ScheduledJobResponse)
def update_scheduled_job(
job_id: int,
payload: ScheduledJobUpdate,
request: Request,
db: DbSession,
_admin: AdminUser,
) -> ScheduledJobResponse:
"""
Update schedule configuration or enabled state for a job.
Only the fields included in the request body are modified.
Changes to the Celery Beat schedule take effect after the worker restarts.
Requires admin privileges.
"""
job = db.query(ScheduledJob).filter(ScheduledJob.id == job_id).first()
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scheduled job not found")
update_data = payload.model_dump(exclude_none=True)
if not update_data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update")
for field, value in update_data.items():
setattr(job, field, value)
job.updated_at = datetime.now(timezone.utc)
try:
db.commit()
db.refresh(job)
except Exception as exc:
db.rollback()
logger.error("Failed to update scheduled job %s: %s", job_id, exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update scheduled job",
) from exc
logger.info("Admin updated scheduled job %s (id=%s): %s", job.name, job_id, update_data)
return job # type: ignore[return-value]
@router.post("/{job_id}/run-now")
def run_scheduled_job_now(
job_id: int,
request: Request,
db: DbSession,
_admin: AdminUser,
) -> dict[str, Any]:
"""
Immediately dispatch the Celery task for the given scheduled job.
The task is sent to the default queue; its result is tracked asynchronously
via the ``last_run_at`` / ``last_run_status`` fields updated by the task
itself.
Requires admin privileges.
"""
job = db.query(ScheduledJob).filter(ScheduledJob.id == job_id).first()
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scheduled job not found")
from app.celery_app import celery as celery_app
task = celery_app.send_task(job.task_name)
logger.info("Admin triggered scheduled job %s (id=%s) manually, task_id=%s", job.name, job_id, task.id)
return {
"status": "dispatched",
"job_id": job_id,
"job_name": job.name,
"task_id": task.id,
}
+77
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
import logging
from celery.schedules import crontab
# Ensure tasks are loaded
@@ -9,6 +11,16 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe
from app.celery_app import celery
from app.config import settings
from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401
from app.tasks.batch_tasks import ( # noqa: F401
backfill_missing_metadata,
cleanup_temp_files,
expire_shared_links,
process_new_documents,
prune_old_notifications,
prune_processing_logs,
reprocess_failed_documents,
sync_search_index,
)
from app.tasks.check_credentials import check_credentials
from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
@@ -48,6 +60,8 @@ from app.tasks.webhook_tasks import deliver_webhook_task # noqa: F401
# Register the settings reload signal handler so workers pick up config changes
from app.utils.settings_sync import register_settings_reload_signal
logger = logging.getLogger(__name__)
register_settings_reload_signal()
celery.conf.task_routes = {
@@ -159,3 +173,66 @@ celery.conf.beat_schedule = {
# Remove None entries from beat_schedule
celery.conf.beat_schedule = {k: v for k, v in celery.conf.beat_schedule.items() if v is not None}
# ---------------------------------------------------------------------------
# Load admin-managed scheduled jobs from the database
# ---------------------------------------------------------------------------
# These jobs are defined in the ``scheduled_jobs`` table (seeded by
# ``app.api.scheduled_jobs.seed_default_scheduled_jobs``) and can be
# enabled/disabled and rescheduled via the admin UI at /admin/scheduled-jobs.
# The schedule is read once at worker startup; changes take effect after
# the worker is restarted.
def _load_db_scheduled_jobs() -> None:
"""
Extend ``celery.conf.beat_schedule`` with entries from the ``scheduled_jobs``
database table.
Only rows with ``enabled=True`` are added. Rows whose ``name`` key
already exists in the static schedule (defined above) are skipped so
that hardcoded entries cannot be overridden accidentally.
Failures are logged as warnings and do not prevent the worker from
starting.
"""
try:
from app.database import SessionLocal
from app.models import ScheduledJob
with SessionLocal() as db:
jobs = db.query(ScheduledJob).filter(ScheduledJob.enabled.is_(True)).all()
added = 0
for job in jobs:
if job.name in celery.conf.beat_schedule:
# Static entry takes precedence; skip silently.
continue
if job.schedule_type == "interval" and job.interval_seconds:
from celery.schedules import schedule as interval_schedule
sched = interval_schedule(run_every=job.interval_seconds)
else:
# Default to cron.
sched = crontab(
minute=job.cron_minute,
hour=job.cron_hour,
day_of_week=job.cron_day_of_week,
day_of_month=job.cron_day_of_month,
month_of_year=job.cron_month_of_year,
)
celery.conf.beat_schedule[job.name] = {
"task": job.task_name,
"schedule": sched,
"options": {"expires": 3600},
}
added += 1
logger.info("Loaded %d scheduled job(s) from database into Celery Beat.", added)
except Exception as exc:
logger.warning("Could not load scheduled jobs from database: %s", exc)
_load_db_scheduled_jobs()
+14
View File
@@ -132,6 +132,20 @@ async def lifespan(app: FastAPI):
except Exception:
logging.debug("Default pipeline seeding skipped — DB may not be ready yet") # noqa: S110
# Seed the default scheduled batch processing jobs so they appear in the
# admin UI (/admin/scheduled-jobs) on first startup.
try:
from app.api.scheduled_jobs import seed_default_scheduled_jobs as _seed_jobs
from app.database import SessionLocal as _SessionLocal # noqa: F811
_db_jobs = _SessionLocal()
try:
_seed_jobs(_db_jobs)
finally:
_db_jobs.close()
except Exception:
logging.debug("Scheduled jobs seeding skipped — DB may not be ready yet") # noqa: S110
# Application is now running
yield
+56
View File
@@ -777,3 +777,59 @@ class InAppNotification(Base):
is_read = Column(Boolean, nullable=False, default=False, index=True)
file_id = Column(Integer, nullable=True) # Optional link to FileRecord
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
class ScheduledJob(Base):
"""
Configuration record for an admin-managed scheduled batch processing job.
Each row represents one recurring job entry. The Celery Beat schedule is
built from these rows at worker startup; changes take effect after the
worker process is restarted.
Schedule types
--------------
- ``"cron"`` standard cron expression fields (minute/hour/…)
- ``"interval"`` fixed interval in seconds (e.g. 3600 for hourly)
"""
__tablename__ = "scheduled_jobs"
id = Column(Integer, primary_key=True, index=True)
# Unique machine-readable key used as the Celery Beat schedule entry name.
name = Column(String(100), unique=True, nullable=False, index=True)
# Human-readable display name shown in the admin UI.
display_name = Column(String(255), nullable=False)
# Short description of what the job does.
description = Column(Text, nullable=True)
# Fully-qualified Celery task name, e.g. "app.tasks.batch_tasks.process_new_documents".
task_name = Column(String(255), nullable=False)
# Whether the job is active. Inactive jobs are excluded from the beat schedule.
enabled = Column(Boolean, nullable=False, default=True)
# Schedule type: "cron" or "interval".
schedule_type = Column(String(20), nullable=False, default="cron")
# --- Cron fields (used when schedule_type == "cron") ---
cron_minute = Column(String(50), nullable=False, default="0")
cron_hour = Column(String(50), nullable=False, default="*")
cron_day_of_week = Column(String(50), nullable=False, default="*")
cron_day_of_month = Column(String(50), nullable=False, default="*")
cron_month_of_year = Column(String(50), nullable=False, default="*")
# --- Interval field (used when schedule_type == "interval") ---
# Interval in seconds; e.g. 3600 = hourly, 86400 = daily.
interval_seconds = Column(Integer, nullable=True)
# Timestamps populated by the worker after each execution.
last_run_at = Column(DateTime(timezone=True), nullable=True)
last_run_status = Column(String(20), nullable=True) # "success", "failed", "running"
last_run_detail = Column(Text, nullable=True) # Brief result summary or error
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+669
View File
@@ -0,0 +1,669 @@
"""
Scheduled batch processing tasks for DocuElevate.
This module provides Celery tasks that can be scheduled via Celery Beat
and managed through the admin UI (``/admin/scheduled-jobs``):
Core batch jobs
---------------
- ``process_new_documents`` Queue documents that have never been processed.
- ``reprocess_failed_documents`` Re-queue documents whose processing failed.
- ``cleanup_temp_files`` Remove stale files from the ``workdir/tmp`` directory.
Maintenance / housekeeping jobs
--------------------------------
- ``expire_shared_links`` Auto-revoke SharedLinks whose ``expires_at`` has passed.
- ``prune_processing_logs`` Delete old rows from ``processing_logs`` and
``settings_audit_log`` to prevent unbounded table growth.
- ``prune_old_notifications`` Delete old read ``in_app_notifications`` rows.
- ``backfill_missing_metadata`` Re-trigger AI metadata extraction for completed files
that have OCR text but no ``ai_metadata``.
- ``sync_search_index`` Index documents in Meilisearch that have OCR text /
metadata but are not yet in the search index.
Each task records its execution result back to the ``ScheduledJob`` table so
the admin UI can display last-run times and statuses.
"""
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import (
FileProcessingStep,
FileRecord,
InAppNotification,
ProcessingLog,
ScheduledJob,
SettingsAuditLog,
SharedLink,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
_PROCESSING_STEPS = {
"create_file_record",
"check_text",
"extract_text",
"process_with_ocr",
"process_with_azure_document_intelligence",
"extract_metadata_with_gpt",
"embed_metadata_into_pdf",
"finalize_document_storage",
"send_to_all_destinations",
}
def _update_job_status(job_name: str, status: str, detail: str) -> None:
"""Persist run status back to the ScheduledJob row for display in the UI."""
try:
with SessionLocal() as db:
job = db.query(ScheduledJob).filter(ScheduledJob.name == job_name).first()
if job:
job.last_run_at = datetime.now(timezone.utc)
job.last_run_status = status
job.last_run_detail = detail
db.commit()
except Exception as exc: # pragma: no cover best-effort status update
logger.warning("Could not update ScheduledJob status for %s: %s", job_name, exc)
# ---------------------------------------------------------------------------
# Task: process new (unprocessed) documents
# ---------------------------------------------------------------------------
@celery.task(name="app.tasks.batch_tasks.process_new_documents")
def process_new_documents() -> dict:
"""
Queue all documents that have never been processed.
A document is considered *new* when it has no ``FileProcessingStep`` rows
that match the core pipeline steps. The task loads each qualifying
``FileRecord``, verifies that the original file still exists on disk, and
dispatches ``process_document`` for each one.
Returns a summary dict with ``queued`` and ``skipped`` counts.
"""
from app.tasks.process_document import process_document # avoid circular import
job_name = "process-new-documents"
logger.info("[batch] Starting process_new_documents task")
try:
with SessionLocal() as db:
# Files that already have at least one processing step recorded.
processed_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.step_name.in_(_PROCESSING_STEPS))
.distinct()
.subquery()
)
# Candidate files: non-duplicate records with no processing steps yet.
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(~FileRecord.id.in_(db.query(processed_file_ids.c.file_id)))
.all()
)
queued = 0
skipped = 0
for record in candidates:
if not record.local_filename or not os.path.exists(record.local_filename):
logger.warning(
"[batch] Skipping file_id=%s — local file not found: %s",
record.id,
record.local_filename,
)
skipped += 1
continue
process_document.delay(
record.local_filename,
original_filename=record.original_filename,
file_id=record.id,
owner_id=record.owner_id,
)
queued += 1
detail = f"Queued {queued} document(s) for processing; skipped {skipped} (file not on disk)."
logger.info("[batch] process_new_documents: %s", detail)
_update_job_status(job_name, "success", detail)
return {"queued": queued, "skipped": skipped}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] process_new_documents failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"queued": 0, "skipped": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: reprocess failed documents
# ---------------------------------------------------------------------------
@celery.task(name="app.tasks.batch_tasks.reprocess_failed_documents")
def reprocess_failed_documents() -> dict:
"""
Re-queue documents whose most-recent processing attempt failed.
Only files that have at least one ``FileProcessingStep`` with
``status == "failure"`` **and** no currently ``in_progress`` steps are
selected so that actively-running jobs are not interrupted.
Returns a summary dict with ``queued`` and ``skipped`` counts.
"""
from app.tasks.process_document import process_document # avoid circular import
job_name = "reprocess-failed-documents"
logger.info("[batch] Starting reprocess_failed_documents task")
try:
with SessionLocal() as db:
# Files with at least one failed step.
failed_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.step_name.in_(_PROCESSING_STEPS))
.filter(FileProcessingStep.status == "failure")
.distinct()
.subquery()
)
# Exclude files that are currently being processed.
in_progress_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.status == "in_progress")
.distinct()
.subquery()
)
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(FileRecord.id.in_(db.query(failed_file_ids.c.file_id)))
.filter(~FileRecord.id.in_(db.query(in_progress_file_ids.c.file_id)))
.all()
)
queued = 0
skipped = 0
for record in candidates:
if not record.local_filename or not os.path.exists(record.local_filename):
logger.warning(
"[batch] Skipping file_id=%s — local file not found: %s",
record.id,
record.local_filename,
)
skipped += 1
continue
process_document.delay(
record.local_filename,
original_filename=record.original_filename,
file_id=record.id,
owner_id=record.owner_id,
)
queued += 1
detail = f"Re-queued {queued} failed document(s); skipped {skipped} (file not on disk)."
logger.info("[batch] reprocess_failed_documents: %s", detail)
_update_job_status(job_name, "success", detail)
return {"queued": queued, "skipped": skipped}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] reprocess_failed_documents failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"queued": 0, "skipped": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: clean up temporary files
# ---------------------------------------------------------------------------
#: Files in ``workdir/tmp`` that are older than this threshold are deleted.
_TEMP_FILE_MAX_AGE_HOURS: int = 24
@celery.task(name="app.tasks.batch_tasks.cleanup_temp_files")
def cleanup_temp_files(max_age_hours: int = _TEMP_FILE_MAX_AGE_HOURS) -> dict:
"""
Delete stale files from the ``workdir/tmp`` directory.
A file is considered stale when **both** of the following are true:
1. Its modification time is older than *max_age_hours* (default 24 h).
2. No ``FileRecord.local_filename`` points to it **or** the file is not
referenced by any active in-progress processing step.
This prevents accidental deletion of files that are being actively
processed by the pipeline.
Args:
max_age_hours: Minimum age (in hours) before a temp file is eligible
for deletion. Defaults to 24.
Returns:
A summary dict with ``deleted`` and ``skipped`` counts.
"""
job_name = "cleanup-temp-files"
logger.info("[batch] Starting cleanup_temp_files (max_age_hours=%s)", max_age_hours)
tmp_dir = Path(settings.workdir) / "tmp"
if not tmp_dir.exists():
detail = "workdir/tmp does not exist; nothing to clean."
logger.info("[batch] cleanup_temp_files: %s", detail)
_update_job_status(job_name, "success", detail)
return {"deleted": 0, "skipped": 0}
cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
deleted = 0
skipped = 0
errors = 0
try:
with SessionLocal() as db:
# Collect filenames actively referenced by in-progress processing steps.
in_progress_filenames: set[str] = set()
in_progress_records = (
db.query(FileRecord.local_filename)
.join(FileProcessingStep, FileProcessingStep.file_id == FileRecord.id)
.filter(FileProcessingStep.status == "in_progress")
.distinct()
.all()
)
for row in in_progress_records:
if row.local_filename:
in_progress_filenames.add(os.path.basename(row.local_filename))
# Also collect all filenames referenced by FileRecord.local_filename
# that point into workdir/tmp (files still in the tmp pipeline).
active_tmp_filenames: set[str] = set()
tmp_dir_str = str(tmp_dir.resolve())
active_records = (
db.query(FileRecord.local_filename).filter(FileRecord.local_filename.like(f"{tmp_dir_str}%")).all()
)
for row in active_records:
if row.local_filename:
active_tmp_filenames.add(os.path.basename(row.local_filename))
protected_basenames = in_progress_filenames | active_tmp_filenames
for entry in tmp_dir.iterdir():
if not entry.is_file():
continue
# Check modification time.
try:
mtime = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
except OSError: # pragma: no cover only reachable if file vanishes between iterdir() and stat()
skipped += 1
continue
if mtime >= cutoff:
skipped += 1
continue
if entry.name in protected_basenames:
logger.debug("[batch] cleanup_temp_files: keeping protected file %s", entry.name)
skipped += 1
continue
try:
entry.unlink()
logger.debug("[batch] cleanup_temp_files: deleted %s", entry)
deleted += 1
except OSError as exc:
logger.warning("[batch] cleanup_temp_files: could not delete %s: %s", entry, exc)
errors += 1
detail = f"Deleted {deleted} stale temp file(s); skipped {skipped} (too new or protected); {errors} error(s)."
status = "failed" if errors and not deleted else "success"
logger.info("[batch] cleanup_temp_files: %s", detail)
_update_job_status(job_name, status, detail)
return {"deleted": deleted, "skipped": skipped, "errors": errors}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] cleanup_temp_files failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"deleted": 0, "skipped": 0, "errors": 1, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: expire stale shared links
# ---------------------------------------------------------------------------
@celery.task(name="app.tasks.batch_tasks.expire_shared_links")
def expire_shared_links() -> dict:
"""
Auto-revoke SharedLinks whose ``expires_at`` timestamp has passed.
The ``_is_link_valid`` helper in the shared-links API already blocks
access at request time, but the database rows remain flagged as
``is_active=True``. This task sweeps those rows and sets
``is_active=False`` + ``revoked_at`` so the management UI reflects
the true state and counts are accurate.
Returns a summary dict with ``revoked`` count.
"""
job_name = "expire-shared-links"
logger.info("[batch] Starting expire_shared_links task")
try:
now = datetime.now(timezone.utc)
with SessionLocal() as db:
stale = (
db.query(SharedLink)
.filter(
SharedLink.is_active.is_(True),
SharedLink.expires_at.isnot(None),
SharedLink.expires_at < now,
)
.all()
)
for link in stale:
link.is_active = False
link.revoked_at = now
db.commit()
revoked = len(stale)
detail = f"Revoked {revoked} expired shared link(s)."
logger.info("[batch] expire_shared_links: %s", detail)
_update_job_status(job_name, "success", detail)
return {"revoked": revoked}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] expire_shared_links failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"revoked": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: prune old processing logs
# ---------------------------------------------------------------------------
#: Default retention period for processing logs and audit log rows.
_LOG_RETENTION_DAYS: int = 30
@celery.task(name="app.tasks.batch_tasks.prune_processing_logs")
def prune_processing_logs(retention_days: int = _LOG_RETENTION_DAYS) -> dict:
"""
Delete ``processing_logs`` and ``settings_audit_log`` rows older than
*retention_days* (default 30) to prevent unbounded table growth.
Rows for the most recent *retention_days* days are kept so that recent
activity is still visible in the logs/audit UI.
Args:
retention_days: Number of days of history to keep (default 30).
Returns:
A summary dict with ``processing_logs_deleted`` and
``audit_log_deleted`` counts.
"""
job_name = "prune-processing-logs"
logger.info("[batch] Starting prune_processing_logs (retention_days=%s)", retention_days)
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
try:
with SessionLocal() as db:
pl_deleted = db.query(ProcessingLog).filter(ProcessingLog.timestamp < cutoff).delete()
al_deleted = db.query(SettingsAuditLog).filter(SettingsAuditLog.changed_at < cutoff).delete()
db.commit()
detail = (
f"Deleted {pl_deleted} processing log row(s) and "
f"{al_deleted} settings audit log row(s) older than {retention_days} days."
)
logger.info("[batch] prune_processing_logs: %s", detail)
_update_job_status(job_name, "success", detail)
return {"processing_logs_deleted": pl_deleted, "audit_log_deleted": al_deleted}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] prune_processing_logs failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"processing_logs_deleted": 0, "audit_log_deleted": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: prune old in-app notifications
# ---------------------------------------------------------------------------
#: Default retention period for read notifications.
_NOTIFICATION_RETENTION_DAYS: int = 30
@celery.task(name="app.tasks.batch_tasks.prune_old_notifications")
def prune_old_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> dict:
"""
Delete ``in_app_notifications`` rows that are already read and older than
*retention_days* days (default 30) to prevent unbounded table growth.
Unread notifications are always kept regardless of age so users do not
miss important alerts.
Args:
retention_days: Number of days of read-notification history to keep
(default 30).
Returns:
A summary dict with ``deleted`` count.
"""
job_name = "prune-old-notifications"
logger.info("[batch] Starting prune_old_notifications (retention_days=%s)", retention_days)
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
try:
with SessionLocal() as db:
deleted = (
db.query(InAppNotification)
.filter(
InAppNotification.is_read.is_(True),
InAppNotification.created_at < cutoff,
)
.delete()
)
db.commit()
detail = f"Deleted {deleted} old read notification(s) older than {retention_days} days."
logger.info("[batch] prune_old_notifications: %s", detail)
_update_job_status(job_name, "success", detail)
return {"deleted": deleted}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] prune_old_notifications failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"deleted": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: backfill missing AI metadata
# ---------------------------------------------------------------------------
#: Maximum number of files to process per backfill run.
_METADATA_BACKFILL_BATCH_SIZE: int = 50
@celery.task(name="app.tasks.batch_tasks.backfill_missing_metadata")
def backfill_missing_metadata(batch_size: int = _METADATA_BACKFILL_BATCH_SIZE) -> dict:
"""
Re-trigger AI metadata extraction for documents that have OCR text but
no ``ai_metadata``.
This handles the common case where a document was processed before the AI
metadata extraction step was configured (e.g., before an OpenAI API key
was added), or where the extraction previously failed.
Only files that are **not** currently in-progress and have non-empty
``ocr_text`` are selected. A configurable *batch_size* caps the number
of tasks queued per run to avoid overwhelming the AI provider.
Args:
batch_size: Maximum number of files to queue per run (default 50).
Returns:
A summary dict with ``queued`` count.
"""
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt # avoid circular import
job_name = "backfill-missing-metadata"
logger.info("[batch] Starting backfill_missing_metadata (batch_size=%s)", batch_size)
try:
with SessionLocal() as db:
# Files currently being processed — skip them.
in_progress_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.status == "in_progress")
.distinct()
.subquery()
)
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(FileRecord.ocr_text.isnot(None))
.filter(FileRecord.ocr_text != "")
.filter((FileRecord.ai_metadata.is_(None)) | (FileRecord.ai_metadata == ""))
.filter(~FileRecord.id.in_(db.query(in_progress_file_ids.c.file_id)))
.limit(batch_size)
.all()
)
queued = 0
for record in candidates:
filename = record.local_filename or record.original_filename or f"file_{record.id}"
extract_metadata_with_gpt.delay(
filename,
record.ocr_text,
file_id=record.id,
)
queued += 1
detail = f"Queued {queued} document(s) for AI metadata backfill."
logger.info("[batch] backfill_missing_metadata: %s", detail)
_update_job_status(job_name, "success", detail)
return {"queued": queued}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] backfill_missing_metadata failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"queued": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: sync Meilisearch search index
# ---------------------------------------------------------------------------
#: Maximum documents to index per sync run.
_SEARCH_SYNC_BATCH_SIZE: int = 100
@celery.task(name="app.tasks.batch_tasks.sync_search_index")
def sync_search_index(batch_size: int = _SEARCH_SYNC_BATCH_SIZE) -> dict:
"""
Index documents in Meilisearch that have OCR text or AI metadata but are
not yet present in the search index.
This is useful after:
- Enabling Meilisearch for the first time on an existing installation.
- Recovering from a Meilisearch index wipe or migration.
- Documents processed before search indexing was added to the pipeline.
The task queries the Meilisearch index for existing document IDs, then
finds ``FileRecord`` rows that have processable content (``ocr_text`` or
``ai_metadata``) but are absent from the index, and re-indexes them.
A configurable *batch_size* caps the number of documents indexed per run.
Args:
batch_size: Maximum number of documents to index per run (default 100).
Returns:
A summary dict with ``indexed`` and ``skipped`` counts.
"""
from app.utils.meilisearch_client import get_meilisearch_client, index_document
job_name = "sync-search-index"
logger.info("[batch] Starting sync_search_index (batch_size=%s)", batch_size)
client = get_meilisearch_client()
if client is None:
detail = "Meilisearch is not configured; skipping search index sync."
logger.info("[batch] sync_search_index: %s", detail)
_update_job_status(job_name, "success", detail)
return {"indexed": 0, "skipped": 0, "reason": "meilisearch_not_configured"}
try:
# Fetch the set of file_ids already in the Meilisearch index.
index = client.get_index(settings.meilisearch_index_name)
# Fetch up to 10 000 IDs — sufficient to determine gaps for most installs.
existing_result = index.get_documents({"fields": ["file_id"], "limit": 10000})
existing_ids: set[int] = {doc["file_id"] for doc in existing_result.results if "file_id" in doc}
except Exception as exc:
detail = f"Error fetching existing Meilisearch IDs: {exc}"
logger.error("[batch] sync_search_index: %s", detail)
_update_job_status(job_name, "failed", detail)
return {"indexed": 0, "skipped": 0, "error": str(exc)}
try:
with SessionLocal() as db:
# Files with indexable content that are not already in the index.
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(
(FileRecord.ocr_text.isnot(None) & (FileRecord.ocr_text != ""))
| (FileRecord.ai_metadata.isnot(None) & (FileRecord.ai_metadata != ""))
)
.filter(~FileRecord.id.in_(existing_ids) if existing_ids else True) # type: ignore[arg-type]
.limit(batch_size)
.all()
)
indexed = 0
skipped = 0
for record in candidates:
metadata: dict = {}
if record.ai_metadata:
try:
metadata = json.loads(record.ai_metadata)
except (json.JSONDecodeError, ValueError):
pass
success = index_document(record, record.ocr_text or "", metadata)
if success:
indexed += 1
else:
skipped += 1
detail = f"Indexed {indexed} document(s) into Meilisearch; {skipped} skipped (indexing error)."
logger.info("[batch] sync_search_index: %s", detail)
_update_job_status(job_name, "success", detail)
return {"indexed": indexed, "skipped": skipped}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] sync_search_index failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"indexed": 0, "skipped": 0, "error": str(exc)}
+2
View File
@@ -24,6 +24,7 @@ from app.views.onedrive import router as onedrive_router
from app.views.pipelines import router as pipelines_router # Processing pipelines
from app.views.plans import router as plans_router # Admin Plan Designer
from app.views.queue import router as queue_router
from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs
from app.views.search import router as search_router
from app.views.settings import router as settings_router
from app.views.share import router as share_router
@@ -58,4 +59,5 @@ router.include_router(pipelines_router) # Processing pipelines
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(notifications_router) # User notification dashboard
router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs
router.include_router(help_router) # Built-in help / How-To docs
+44
View File
@@ -0,0 +1,44 @@
"""Admin view: scheduled batch processing jobs management page."""
import logging
from fastapi import HTTPException, Request, status
from fastapi.responses import RedirectResponse
from app.views.base import APIRouter, require_login, settings, templates
logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request):
"""Return the session user if they are an admin, else None."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
logger.warning("Non-admin user attempted to access /admin/scheduled-jobs")
return None
return user
@router.get("/admin/scheduled-jobs")
@require_login
async def scheduled_jobs_page(request: Request):
"""Admin scheduled jobs management page."""
user = _require_admin(request)
if user is None:
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
try:
return templates.TemplateResponse(
"admin_scheduled_jobs.html",
{
"request": request,
"app_version": settings.version,
},
)
except Exception as e:
logger.error(f"Error loading scheduled jobs page: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load scheduled jobs page",
)
+226
View File
@@ -0,0 +1,226 @@
# Scheduled Jobs
DocuElevate includes an admin-managed **Scheduled Jobs** system that runs
recurring maintenance and processing tasks automatically via Celery Beat.
All jobs are visible and configurable through the admin UI at
**Admin → Scheduled Jobs** (`/admin/scheduled-jobs`).
---
## Overview
Scheduled jobs replace the need to run manual batch operations. Each job:
- Runs automatically on its configured cron or interval schedule.
- Can be **enabled** or **disabled** without restarting the worker.
- Can be triggered **immediately** with the *Run Now* button.
- Reports its last-run time, status (`success`, `failed`, `running`), and
a short detail message back to the UI.
> **Note:** Schedule changes (cron expressions, interval values, enable/disable
> toggles) are persisted to the database immediately. However, Celery Beat
> reads the schedule only at worker startup — so changes take effect after
> **restarting the Celery worker**.
> The *Run Now* button dispatches a job immediately and does **not** require
> a restart.
---
## Built-in Jobs
### 1. Process New Documents
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.process_new_documents` |
| **Default schedule** | Every hour (cron `0 */1 * * *`) |
| **Purpose** | Scans for documents that have been uploaded but never processed, then queues them through the full pipeline. |
A document is considered *new* when it has no `FileProcessingStep` rows
matching the core pipeline steps. Only files whose `local_filename` still
exists on disk are queued; others are skipped and counted.
---
### 2. Reprocess Failed Documents
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.reprocess_failed_documents` |
| **Default schedule** | Every 6 hours (cron `30 */6 * * *`) |
| **Purpose** | Finds documents whose last processing step has status `failure` and re-queues them. Skips files that are currently in-progress. |
---
### 3. Clean Up Temporary Files
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.cleanup_temp_files` |
| **Default schedule** | Daily at 03:30 UTC (cron `30 3 * * *`) |
| **Purpose** | Removes stale files from `workdir/tmp`. Only files older than 24 hours that are not referenced by any active processing job are deleted. |
---
### 4. Expire Stale Shared Links
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.expire_shared_links` |
| **Default schedule** | Daily at 01:00 UTC (cron `0 1 * * *`) |
| **Purpose** | Marks shared document links as inactive when their `expires_at` timestamp has passed. |
Access is already blocked at request time, but this task keeps the management
UI counts and statuses accurate.
---
### 5. Prune Old Processing Logs
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.prune_processing_logs` |
| **Default schedule** | Weekly on Sunday at 04:00 UTC (cron `0 4 * * 0`) |
| **Purpose** | Deletes `processing_logs` and `settings_audit_log` rows older than 30 days to prevent unbounded table growth. |
---
### 6. Prune Old Notifications
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.prune_old_notifications` |
| **Default schedule** | Weekly on Sunday at 04:30 UTC (cron `30 4 * * 0`) |
| **Purpose** | Deletes *read* `in_app_notifications` rows older than 30 days. Unread notifications are never deleted. |
---
### 7. Backfill Missing AI Metadata
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.backfill_missing_metadata` |
| **Default schedule** | Every 6 hours (cron `0 */6 * * *`) |
| **Purpose** | Re-triggers AI metadata extraction for documents that have OCR text but no `ai_metadata`. Processes up to 50 documents per run. |
This is particularly useful when:
- An AI provider (OpenAI / Azure) was configured after documents were already
ingested.
- A previous metadata extraction attempt failed.
---
### 8. Sync Search Index
| Field | Value |
|---|---|
| **Task** | `app.tasks.batch_tasks.sync_search_index` |
| **Default schedule** | Hourly (cron `15 */1 * * *`) |
| **Purpose** | Indexes documents in Meilisearch that have OCR text or AI metadata but are not yet present in the search index. Processes up to 100 documents per run. |
This is useful after:
- Enabling Meilisearch for the first time on an existing installation.
- Recovering from a Meilisearch index wipe or migration.
---
## Managing Schedules
### Editing a schedule
1. Navigate to **Admin → Scheduled Jobs**.
2. Click **Edit Schedule** on the job card.
3. Choose **Cron** or **Interval** and fill in the fields.
4. Click **Save Changes**.
**Restart the worker** for the new schedule to take effect:
```bash
# Docker Compose
docker compose restart worker
# Kubernetes
kubectl rollout restart deployment/docuelevate-worker
```
### Cron format
Cron expressions follow the standard 5-field format:
```
minute hour day-of-month month day-of-week
```
Examples:
| Expression | Meaning |
|---|---|
| `0 * * * *` | Every hour on the hour |
| `0 2 * * *` | Daily at 02:00 UTC |
| `30 4 * * 0` | Every Sunday at 04:30 UTC |
| `*/15 * * * *` | Every 15 minutes |
### Interval format
For interval schedules, provide the number of **seconds**:
| Seconds | Meaning |
|---|---|
| `60` | Every minute |
| `3600` | Every hour |
| `86400` | Every day |
The minimum interval is **60 seconds**.
---
## Enabling / Disabling a job
Click the toggle on the job card to enable or disable the job. The change
is saved immediately, but the updated schedule only takes effect after the
worker restarts.
Disabling a job does **not** cancel an in-flight execution — it only prevents
Celery Beat from scheduling new executions.
---
## Run Now
Click **Run Now** on any job card to dispatch it immediately, regardless of
its schedule or enabled status. The task is sent to the `default` Celery
queue and will be picked up by the next available worker.
The UI shows a spinner while the dispatch request is in-flight and displays
the Celery task ID on success. Refresh the page after a few seconds to see
the updated last-run status.
---
## Adding custom jobs
Custom batch jobs can be added by:
1. Creating a new Celery task in `app/tasks/` (or any existing tasks module).
2. Adding a row to `DEFAULT_JOBS` in `app/api/scheduled_jobs.py`.
3. Running the app — the new job will be seeded automatically on startup.
The task function should call `_update_job_status(job_name, status, detail)`
at the end of its execution so the admin UI reflects the outcome.
---
## Architecture notes
- **Seeding**: Default jobs are seeded into the `scheduled_jobs` database
table when the FastAPI application starts up (via the lifespan handler in
`app/main.py`). Seeding is idempotent — re-running does not create
duplicate rows.
- **Beat integration**: `app/celery_worker.py` calls `_load_db_scheduled_jobs()`
at import time to extend `celery.conf.beat_schedule` with enabled DB jobs.
Static hardcoded entries always take precedence over DB entries with the
same key.
- **Status tracking**: Each task calls `_update_job_status()` in
`app/tasks/batch_tasks.py` to persist `last_run_at`, `last_run_status`, and
`last_run_detail` back to the `scheduled_jobs` table.
@@ -0,0 +1,558 @@
{% extends "base.html" %}
{% block title %}Scheduled Jobs Admin DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="scheduledJobsApp()">
<!-- ── Header ─────────────────────────────────────────────────────────────── -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
<i class="fas fa-clock text-indigo-500" aria-hidden="true"></i>
Scheduled Jobs
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">Admin Only</span>
</h1>
<p class="text-gray-500 text-sm mt-1">
Manage and trigger scheduled batch processing jobs. Schedule changes take effect after the worker restarts.
</p>
</div>
<button
type="button"
@click="fetchJobs()"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400"
aria-label="Refresh job list"
>
<i class="fas fa-sync-alt mr-2" aria-hidden="true"></i> Refresh
</button>
</div>
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
<div x-show="alert.show" x-transition class="mb-4" role="alert" :aria-live="alert.type === 'error' ? 'assertive' : 'polite'">
<div
:class="alert.type === 'success'
? 'bg-green-50 border-green-400 text-green-800'
: 'bg-red-50 border-red-400 text-red-800'"
class="border-l-4 p-4 rounded"
>
<p class="font-semibold" x-text="alert.title"></p>
<p class="text-sm mt-1" x-text="alert.message"></p>
</div>
</div>
<!-- ── Info box ───────────────────────────────────────────────────────────── -->
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800 flex items-start gap-3">
<i class="fas fa-info-circle mt-0.5 flex-shrink-0" aria-hidden="true"></i>
<div>
<strong>How scheduling works:</strong>
These jobs are executed by the Celery Beat scheduler running inside the worker container.
Schedule changes (cron / interval) and enable / disable toggles are persisted immediately,
but the Celery Beat process reads the schedule only at startup — so changes take effect
after the worker is restarted. You can always trigger any job <strong>right now</strong>
using the <em>Run Now</em> button without restarting.
</div>
</div>
<!-- ── Loading spinner ────────────────────────────────────────────────────── -->
<div x-show="loading" class="flex justify-center py-12" aria-live="polite" aria-label="Loading scheduled jobs">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" role="status">
<span class="sr-only">Loading…</span>
</div>
</div>
<!-- ── Jobs list ──────────────────────────────────────────────────────────── -->
<div x-show="!loading" class="space-y-4">
<template x-if="jobs.length === 0">
<div class="text-center py-12 text-gray-500">
<i class="fas fa-clock text-4xl mb-3 opacity-30" aria-hidden="true"></i>
<p>No scheduled jobs found.</p>
</div>
</template>
<template x-for="job in jobs" :key="job.id">
<div class="bg-white shadow rounded-lg overflow-hidden">
<!-- Card header -->
<div class="px-6 py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-gray-100">
<div class="flex items-center gap-3">
<!-- Enable / disable toggle -->
<button
type="button"
@click="toggleEnabled(job)"
:aria-label="job.enabled ? 'Disable job ' + job.display_name : 'Enable job ' + job.display_name"
:aria-pressed="job.enabled"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="job.enabled ? 'bg-indigo-600' : 'bg-gray-200'"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="job.enabled ? 'translate-x-5' : 'translate-x-0'"
></span>
</button>
<div>
<h2 class="text-base font-semibold text-gray-900" x-text="job.display_name"></h2>
<p class="text-xs text-gray-500 mt-0.5" x-text="job.description"></p>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<!-- Status badge -->
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="{
'bg-green-100 text-green-800': job.enabled,
'bg-gray-100 text-gray-600': !job.enabled
}"
>
<span
class="w-1.5 h-1.5 rounded-full mr-1.5"
:class="job.enabled ? 'bg-green-500' : 'bg-gray-400'"
></span>
<span x-text="job.enabled ? 'Active' : 'Disabled'"></span>
</span>
<!-- Run Now button -->
<button
type="button"
@click="runNow(job)"
:disabled="runningJobIds.includes(job.id)"
class="inline-flex items-center px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
:aria-label="'Run job ' + job.display_name + ' now'"
>
<template x-if="runningJobIds.includes(job.id)">
<i class="fas fa-spinner fa-spin mr-1.5" aria-hidden="true"></i>
</template>
<template x-if="!runningJobIds.includes(job.id)">
<i class="fas fa-play mr-1.5" aria-hidden="true"></i>
</template>
Run Now
</button>
<!-- Edit button -->
<button
type="button"
@click="openEditModal(job)"
class="inline-flex items-center px-3 py-1.5 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
:aria-label="'Edit schedule for ' + job.display_name"
>
<i class="fas fa-edit mr-1.5" aria-hidden="true"></i> Edit Schedule
</button>
</div>
</div>
<!-- Card body: schedule info + last run -->
<div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
<!-- Schedule type -->
<div>
<span class="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Schedule Type</span>
<span
class="inline-flex items-center gap-1 font-medium text-gray-700"
x-text="job.schedule_type === 'cron' ? 'Cron' : 'Interval'"
></span>
</div>
<!-- Schedule expression -->
<div>
<span class="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Schedule</span>
<code
class="text-xs bg-gray-100 rounded px-1.5 py-0.5 font-mono text-gray-800"
x-text="formatSchedule(job)"
></code>
</div>
<!-- Last run -->
<div>
<span class="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Last Run</span>
<span
class="text-gray-700"
x-text="job.last_run_at ? formatDate(job.last_run_at) : 'Never'"
></span>
</div>
<!-- Last result -->
<div>
<span class="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Last Result</span>
<template x-if="job.last_run_status">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
:class="{
'bg-green-100 text-green-800': job.last_run_status === 'success',
'bg-red-100 text-red-800': job.last_run_status === 'failed',
'bg-yellow-100 text-yellow-800': job.last_run_status === 'running'
}"
>
<i
:class="{
'fas fa-check-circle': job.last_run_status === 'success',
'fas fa-exclamation-circle': job.last_run_status === 'failed',
'fas fa-spinner fa-spin': job.last_run_status === 'running'
}"
aria-hidden="true"
></i>
<span x-text="job.last_run_status.charAt(0).toUpperCase() + job.last_run_status.slice(1)"></span>
</span>
</template>
<template x-if="!job.last_run_status">
<span class="text-gray-400 text-xs"></span>
</template>
<template x-if="job.last_run_detail">
<p class="text-xs text-gray-500 mt-1" x-text="job.last_run_detail"></p>
</template>
</div>
</div>
<!-- Task name (collapsed) -->
<div class="px-6 pb-4">
<details class="text-xs text-gray-400">
<summary class="cursor-pointer hover:text-gray-600 select-none">Technical details</summary>
<div class="mt-2 space-y-1">
<p><span class="font-medium">Task:</span> <code class="bg-gray-100 rounded px-1" x-text="job.task_name"></code></p>
<p><span class="font-medium">Job key:</span> <code class="bg-gray-100 rounded px-1" x-text="job.name"></code></p>
</div>
</details>
</div>
</div>
</template>
</div>
<!-- ── Edit schedule modal ────────────────────────────────────────────────── -->
<div
x-show="editModal.open"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
:aria-labelledby="'edit-modal-title-' + (editModal.job ? editModal.job.id : '')"
x-cloak
>
<div class="flex items-center justify-center min-h-screen px-4 py-8">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75" @click="closeEditModal()" aria-hidden="true"></div>
<!-- Panel -->
<div
class="relative bg-white rounded-lg shadow-xl w-full max-w-lg p-6 z-10"
@click.stop
>
<div class="mb-4 flex items-center justify-between">
<h2
:id="'edit-modal-title-' + (editModal.job ? editModal.job.id : '')"
class="text-lg font-semibold text-gray-900"
x-text="'Edit Schedule: ' + (editModal.job ? editModal.job.display_name : '')"
></h2>
<button
type="button"
@click="closeEditModal()"
class="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
aria-label="Close edit modal"
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="saveSchedule()" novalidate>
<!-- Schedule type -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1" for="scheduleType">Schedule Type</label>
<select
id="scheduleType"
x-model="editModal.form.schedule_type"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value="cron">Cron</option>
<option value="interval">Interval</option>
</select>
</div>
<!-- Cron fields -->
<div x-show="editModal.form.schedule_type === 'cron'" class="space-y-3 mb-4">
<p class="text-xs text-gray-500">
Standard cron expressions. Use <code class="bg-gray-100 px-1 rounded">*</code> for every value,
<code class="bg-gray-100 px-1 rounded">*/n</code> for every n-th value,
<code class="bg-gray-100 px-1 rounded">0,6</code> for specific values.
</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1" for="cronMinute">Minute</label>
<input id="cronMinute" type="text" x-model="editModal.form.cron_minute"
class="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="0" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1" for="cronHour">Hour</label>
<input id="cronHour" type="text" x-model="editModal.form.cron_hour"
class="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="*" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1" for="cronDow">Day of Week</label>
<input id="cronDow" type="text" x-model="editModal.form.cron_day_of_week"
class="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="*" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1" for="cronDom">Day of Month</label>
<input id="cronDom" type="text" x-model="editModal.form.cron_day_of_month"
class="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="*" required />
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1" for="cronMoy">Month</label>
<input id="cronMoy" type="text" x-model="editModal.form.cron_month_of_year"
class="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="*" required />
</div>
</div>
</div>
<!-- Interval field -->
<div x-show="editModal.form.schedule_type === 'interval'" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1" for="intervalSeconds">Interval (seconds)</label>
<input
id="intervalSeconds"
type="number"
min="60"
x-model.number="editModal.form.interval_seconds"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="3600"
/>
<p class="text-xs text-gray-500 mt-1">Minimum 60 seconds. Examples: 3600 = hourly, 86400 = daily.</p>
</div>
<!-- Form errors -->
<div x-show="editModal.error" class="mb-4 bg-red-50 border border-red-300 text-red-700 rounded p-3 text-sm" x-text="editModal.error" role="alert"></div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<button
type="button"
@click="closeEditModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Cancel
</button>
<button
type="submit"
:disabled="editModal.saving"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<i x-show="editModal.saving" class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function scheduledJobsApp() {
return {
jobs: [],
loading: true,
runningJobIds: [],
alert: { show: false, type: 'success', title: '', message: '' },
editModal: {
open: false,
job: null,
saving: false,
error: null,
form: {
schedule_type: 'cron',
cron_minute: '0',
cron_hour: '*',
cron_day_of_week: '*',
cron_day_of_month: '*',
cron_month_of_year: '*',
interval_seconds: 3600,
},
},
init() {
this.fetchJobs();
},
async fetchJobs() {
this.loading = true;
try {
const resp = await fetch('/api/admin/scheduled-jobs', { credentials: 'same-origin' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
this.jobs = await resp.json();
} catch (err) {
this.showAlert('error', 'Failed to load jobs', err.message);
} finally {
this.loading = false;
}
},
async toggleEnabled(job) {
const newValue = !job.enabled;
try {
const resp = await fetch(`/api/admin/scheduled-jobs/${job.id}`, {
method: 'PATCH',
headers: this._headers(),
body: JSON.stringify({ enabled: newValue }),
credentials: 'same-origin',
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.detail || `HTTP ${resp.status}`);
}
const updated = await resp.json();
const idx = this.jobs.findIndex(j => j.id === job.id);
if (idx !== -1) this.jobs[idx] = updated;
this.showAlert(
'success',
updated.enabled ? 'Job enabled' : 'Job disabled',
`"${updated.display_name}" has been ${updated.enabled ? 'enabled' : 'disabled'}. Restart the worker for changes to take effect.`
);
} catch (err) {
this.showAlert('error', 'Update failed', err.message);
}
},
async runNow(job) {
if (this.runningJobIds.includes(job.id)) return;
this.runningJobIds.push(job.id);
try {
const resp = await fetch(`/api/admin/scheduled-jobs/${job.id}/run-now`, {
method: 'POST',
headers: this._headers(),
credentials: 'same-origin',
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.detail || `HTTP ${resp.status}`);
}
const data = await resp.json();
this.showAlert(
'success',
'Job dispatched',
`"${job.display_name}" has been queued (task ID: ${data.task_id}). Refresh to see the updated last-run status.`
);
// Refresh after a short delay so the status can update.
setTimeout(() => this.fetchJobs(), 3000);
} catch (err) {
this.showAlert('error', 'Failed to dispatch job', err.message);
} finally {
this.runningJobIds = this.runningJobIds.filter(id => id !== job.id);
}
},
openEditModal(job) {
this.editModal.job = job;
this.editModal.error = null;
this.editModal.saving = false;
this.editModal.form = {
schedule_type: job.schedule_type,
cron_minute: job.cron_minute,
cron_hour: job.cron_hour,
cron_day_of_week: job.cron_day_of_week,
cron_day_of_month: job.cron_day_of_month,
cron_month_of_year: job.cron_month_of_year,
interval_seconds: job.interval_seconds || 3600,
};
this.editModal.open = true;
},
closeEditModal() {
this.editModal.open = false;
this.editModal.job = null;
this.editModal.error = null;
},
async saveSchedule() {
if (!this.editModal.job) return;
this.editModal.saving = true;
this.editModal.error = null;
const payload = { schedule_type: this.editModal.form.schedule_type };
if (this.editModal.form.schedule_type === 'cron') {
payload.cron_minute = this.editModal.form.cron_minute;
payload.cron_hour = this.editModal.form.cron_hour;
payload.cron_day_of_week = this.editModal.form.cron_day_of_week;
payload.cron_day_of_month = this.editModal.form.cron_day_of_month;
payload.cron_month_of_year = this.editModal.form.cron_month_of_year;
} else {
const secs = parseInt(this.editModal.form.interval_seconds, 10);
if (!secs || secs < 60) {
this.editModal.error = 'Interval must be at least 60 seconds.';
this.editModal.saving = false;
return;
}
payload.interval_seconds = secs;
}
try {
const resp = await fetch(`/api/admin/scheduled-jobs/${this.editModal.job.id}`, {
method: 'PATCH',
headers: this._headers(),
body: JSON.stringify(payload),
credentials: 'same-origin',
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.detail || `HTTP ${resp.status}`);
}
const updated = await resp.json();
const idx = this.jobs.findIndex(j => j.id === this.editModal.job.id);
if (idx !== -1) this.jobs[idx] = updated;
this.closeEditModal();
this.showAlert(
'success',
'Schedule updated',
`"${updated.display_name}" schedule saved. Restart the worker for changes to take effect.`
);
} catch (err) {
this.editModal.error = err.message;
} finally {
this.editModal.saving = false;
}
},
formatSchedule(job) {
if (job.schedule_type === 'interval') {
const s = job.interval_seconds || 0;
if (s >= 86400) return `Every ${s / 86400}d`;
if (s >= 3600) return `Every ${s / 3600}h`;
if (s >= 60) return `Every ${s / 60}m`;
return `Every ${s}s`;
}
return `${job.cron_minute} ${job.cron_hour} ${job.cron_day_of_month} ${job.cron_month_of_year} ${job.cron_day_of_week}`;
},
formatDate(isoStr) {
try {
return new Date(isoStr).toLocaleString(undefined, {
dateStyle: 'short', timeStyle: 'short'
});
} catch {
return isoStr;
}
},
showAlert(type, title, message) {
this.alert = { show: true, type, title, message };
setTimeout(() => { this.alert.show = false; }, 6000);
},
_headers() {
const meta = document.querySelector('meta[name="csrf-token"]');
const headers = { 'Content-Type': 'application/json' };
if (meta) headers['X-CSRF-Token'] = meta.content;
return headers;
},
};
}
</script>
{% endblock %}
+6
View File
@@ -165,6 +165,9 @@
<a href="/admin/queue" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-stream w-4 mr-2 text-blue-500" aria-hidden="true"></i> Queue Monitor
</a>
<a href="/admin/scheduled-jobs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-clock w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Scheduled Jobs
</a>
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> Backup &amp; Restore
</a>
@@ -332,6 +335,9 @@
<a href="/admin/queue" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-stream mr-2 text-blue-400" aria-hidden="true"></i> Queue Monitor
</a>
<a href="/admin/scheduled-jobs" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-clock mr-2 text-indigo-500" aria-hidden="true"></i> Scheduled Jobs
</a>
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> Backup &amp; Restore
</a>
@@ -0,0 +1,51 @@
"""Add scheduled_jobs table for admin-managed batch processing schedules.
Revision ID: 026_add_scheduled_jobs
Revises: 025_add_user_notifications
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "026_add_scheduled_jobs"
down_revision: Union[str, None] = "025_add_user_notifications"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create scheduled_jobs table."""
op.create_table(
"scheduled_jobs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("task_name", sa.String(255), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("schedule_type", sa.String(20), nullable=False, server_default="cron"),
sa.Column("cron_minute", sa.String(50), nullable=False, server_default="0"),
sa.Column("cron_hour", sa.String(50), nullable=False, server_default="*"),
sa.Column("cron_day_of_week", sa.String(50), nullable=False, server_default="*"),
sa.Column("cron_day_of_month", sa.String(50), nullable=False, server_default="*"),
sa.Column("cron_month_of_year", sa.String(50), nullable=False, server_default="*"),
sa.Column("interval_seconds", sa.Integer(), nullable=True),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_run_status", sa.String(20), nullable=True),
sa.Column("last_run_detail", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", name="uq_scheduled_jobs_name"),
)
op.create_index("ix_scheduled_jobs_id", "scheduled_jobs", ["id"])
op.create_index("ix_scheduled_jobs_name", "scheduled_jobs", ["name"])
def downgrade() -> None:
"""Drop scheduled_jobs table."""
op.drop_index("ix_scheduled_jobs_name", "scheduled_jobs")
op.drop_index("ix_scheduled_jobs_id", "scheduled_jobs")
op.drop_table("scheduled_jobs")
+1
View File
@@ -67,6 +67,7 @@ from app.models import ( # noqa: F401, E402
PipelineStep,
ProcessingLog,
SavedSearch,
ScheduledJob,
UserImapAccount,
UserIntegration,
UserProfile,
File diff suppressed because it is too large Load Diff