diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3ed0fae --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI +from contextlib import asynccontextmanager +from app.database import engine, Base +from app.routers import sources, jobs + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + +app = FastAPI(title="Backup Tool API", lifespan=lifespan) + +app.include_router(sources.router) +app.include_router(jobs.router) diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py new file mode 100644 index 0000000..2878e83 --- /dev/null +++ b/backend/app/routers/jobs.py @@ -0,0 +1,98 @@ +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List +from app.database import get_db +from app.models import Job, Schedule +from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate +from backup.engine import BackupEngine + +router = APIRouter(prefix="/api/jobs", tags=["jobs"]) + +@router.get("/", response_model=List[JobSchema]) +async def list_jobs(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Job)) + jobs = result.scalars().all() + return jobs + +@router.post("/", response_model=JobSchema) +async def create_job(job: JobCreate, db: AsyncSession = Depends(get_db)): + db_job = Job(**job.model_dump()) + db.add(db_job) + await db.commit() + await db.refresh(db_job) + return db_job + +@router.get("/{job_id}", response_model=JobSchema) +async def get_job(job_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + +@router.put("/{job_id}", response_model=JobSchema) +async def update_job( + job_id: int, + job_update: JobUpdate, + db: AsyncSession = Depends(get_db) +): + result = await db.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + update_data = job_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(job, field, value) + + await db.commit() + await db.refresh(job) + return job + +@router.delete("/{job_id}") +async def delete_job(job_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + await db.delete(job) + await db.commit() + return {"message": "Job deleted"} + +@router.post("/{job_id}/run") +async def run_job( + job_id: int, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) +): + result = await db.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + # Run in background + async def execute(): + engine = BackupEngine(db) + await engine.execute_job(job_id, triggered_by="manual") + + background_tasks.add_task(execute) + return {"message": "Job execution started"} + +@router.post("/{job_id}/schedule", response_model=JobSchema) +async def create_schedule( + job_id: int, + schedule: ScheduleCreate, + db: AsyncSession = Depends(get_db) +): + result = await db.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + db_schedule = Schedule(**schedule.model_dump()) + db.add(db_schedule) + await db.commit() + await db.refresh(job) + return job diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py new file mode 100644 index 0000000..fc7fb83 --- /dev/null +++ b/backend/tests/test_jobs.py @@ -0,0 +1,97 @@ +import pytest +from httpx import AsyncClient +from fastapi.testclient import TestClient +from app.main import app + +@pytest.fixture +def client(): + with TestClient(app) as c: + yield c + +def test_create_job(client): + # Create a source first (job requires source_id) + source_resp = client.post("/api/sources/", json={ + "name": "Test Source", + "type": "local", + "config": {"path": "/tmp/test"} + }) + assert source_resp.status_code == 200 + source_id = source_resp.json()["id"] + + response = client.post("/api/jobs/", json={ + "name": "Test Job", + "source_id": source_id, + "strategy": "full", + "destination_path": "/tmp/backups", + "exclude_patterns": [], + "enabled": True + }) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Test Job" + assert data["source_id"] == source_id + assert data["strategy"] == "full" + assert "id" in data + +def test_run_job(client): + # Create source + source_resp = client.post("/api/sources/", json={ + "name": "Test Source", + "type": "local", + "config": {"path": "/tmp/test"} + }) + source_id = source_resp.json()["id"] + + # Create job + job_resp = client.post("/api/jobs/", json={ + "name": "Test Job", + "source_id": source_id, + "strategy": "full", + "destination_path": "/tmp/backups", + "exclude_patterns": [], + "enabled": True + }) + job_id = job_resp.json()["id"] + + response = client.post(f"/api/jobs/{job_id}/run") + assert response.status_code == 200 + assert response.json()["message"] == "Job execution started" + +def test_update_job(client): + # Create source + source_resp = client.post("/api/sources/", json={ + "name": "Test Source", + "type": "local", + "config": {"path": "/tmp/test"} + }) + source_id = source_resp.json()["id"] + + # Create job + job_resp = client.post("/api/jobs/", json={ + "name": "Original Name", + "source_id": source_id, + "strategy": "full", + "destination_path": "/tmp/backups", + "exclude_patterns": [], + "enabled": True + }) + job_id = job_resp.json()["id"] + + response = client.put(f"/api/jobs/{job_id}", json={ + "name": "Updated Name" + }) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Name" + assert response.json()["strategy"] == "full" # Unchanged + +def test_get_job_not_found(client): + response = client.get("/api/jobs/99999") + assert response.status_code == 404 + +def test_update_job_not_found(client): + response = client.put("/api/jobs/99999", json={"name": "Test"}) + assert response.status_code == 404 + +def test_delete_job_not_found(client): + response = client.delete("/api/jobs/99999") + assert response.status_code == 404