244 lines
7.8 KiB
Python
244 lines
7.8 KiB
Python
from typing import List, Optional
|
|
from datetime import datetime, timezone
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, desc
|
|
|
|
from app.core.database import get_db
|
|
from app.models.models import User, Client, BackupJob
|
|
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister
|
|
from app.api.deps import get_current_user, require_admin, get_current_client
|
|
from app.services.event_service import log_event
|
|
from app.ws.manager import ws_manager
|
|
|
|
router = APIRouter(prefix="/jobs", tags=["Backup Jobs"])
|
|
|
|
@router.get("", response_model=List[JobResponse])
|
|
async def list_jobs(
|
|
client_id: Optional[int] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
query = select(BackupJob)
|
|
if client_id:
|
|
query = query.where(BackupJob.client_id == client_id)
|
|
query = query.order_by(desc(BackupJob.created_at))
|
|
result = await db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
@router.get("/agent/assigned", response_model=List[JobResponse])
|
|
async def get_agent_jobs(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_client: Client = Depends(get_current_client)
|
|
):
|
|
"""Called by the Windows Agent to query its active backup jobs."""
|
|
query = select(BackupJob).where(BackupJob.client_id == current_client.id, BackupJob.is_active == True)
|
|
result = await db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
@router.post("/agent/register", response_model=JobResponse)
|
|
async def agent_register_job(
|
|
payload: AgentJobRegister,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_client: Client = Depends(get_current_client)
|
|
):
|
|
"""Called by the Windows Agent to register a new local job/folder on the server."""
|
|
count_res = await db.execute(select(func.count(BackupJob.id)))
|
|
job_count = count_res.scalar() or 0
|
|
job_code = f"JOB-{job_count + 1:03d}"
|
|
|
|
job = BackupJob(
|
|
job_code=job_code,
|
|
client_id=current_client.id,
|
|
name=payload.name,
|
|
source_path=payload.source_path,
|
|
file_patterns=payload.file_patterns,
|
|
schedule_cron=payload.schedule_cron,
|
|
keep_daily=payload.keep_daily,
|
|
keep_weekly=payload.keep_weekly,
|
|
keep_monthly=payload.keep_monthly,
|
|
min_stable_time_seconds=payload.min_stable_time_seconds,
|
|
status="IDLE",
|
|
is_active=True
|
|
)
|
|
db.add(job)
|
|
await db.commit()
|
|
await db.refresh(job)
|
|
|
|
await log_event(
|
|
db=db,
|
|
event_type="JOB_CREATED",
|
|
message=f"Agent registered backup job '{job.name}' ({job.job_code}) from device.",
|
|
severity="INFO",
|
|
client_id=current_client.id,
|
|
job_id=job.id
|
|
)
|
|
|
|
return job
|
|
|
|
@router.delete("/agent/{job_id}")
|
|
async def agent_delete_job(
|
|
job_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_client: Client = Depends(get_current_client)
|
|
):
|
|
"""Called by the Windows Agent to delete a backup job it owns."""
|
|
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id, BackupJob.client_id == current_client.id))
|
|
job = res.scalar_one_or_none()
|
|
if not job:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Job not found or not owned by this client"
|
|
)
|
|
|
|
await db.delete(job)
|
|
await db.commit()
|
|
|
|
await log_event(
|
|
db=db,
|
|
event_type="JOB_DELETED",
|
|
message=f"Agent deleted backup job {job.job_code} from device.",
|
|
severity="WARNING",
|
|
client_id=current_client.id
|
|
)
|
|
|
|
return {"message": "Job successfully deleted by agent"}
|
|
|
|
@router.post("", response_model=JobResponse)
|
|
async def create_job(
|
|
payload: JobCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
admin_user: User = Depends(require_admin)
|
|
):
|
|
# Verify client exists
|
|
client_res = await db.execute(select(Client).where(Client.id == payload.client_id))
|
|
client = client_res.scalar_one_or_none()
|
|
if not client:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
|
|
|
|
count_res = await db.execute(select(func.count(BackupJob.id)))
|
|
job_count = count_res.scalar() or 0
|
|
job_code = f"JOB-{job_count + 1:03d}"
|
|
|
|
job = BackupJob(
|
|
job_code=job_code,
|
|
client_id=payload.client_id,
|
|
name=payload.name,
|
|
source_path=payload.source_path,
|
|
file_patterns=payload.file_patterns,
|
|
schedule_cron=payload.schedule_cron,
|
|
keep_daily=payload.keep_daily,
|
|
keep_weekly=payload.keep_weekly,
|
|
keep_monthly=payload.keep_monthly,
|
|
min_stable_time_seconds=payload.min_stable_time_seconds,
|
|
status="IDLE",
|
|
is_active=True
|
|
)
|
|
db.add(job)
|
|
await db.commit()
|
|
await db.refresh(job)
|
|
|
|
await log_event(
|
|
db=db,
|
|
event_type="JOB_CREATED",
|
|
message=f"Created backup job '{job.name}' ({job.job_code}) for client {client.name}.",
|
|
severity="INFO",
|
|
client_id=client.id,
|
|
job_id=job.id,
|
|
user_email=admin_user.email
|
|
)
|
|
|
|
return job
|
|
|
|
@router.get("/{job_id}", response_model=JobResponse)
|
|
async def get_job(
|
|
job_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
|
job = res.scalar_one_or_none()
|
|
if not job:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
|
return job
|
|
|
|
@router.put("/{job_id}", response_model=JobResponse)
|
|
async def update_job(
|
|
job_id: int,
|
|
payload: JobUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
admin_user: User = Depends(require_admin)
|
|
):
|
|
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
|
job = res.scalar_one_or_none()
|
|
if not job:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
|
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(job, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(job)
|
|
|
|
await log_event(
|
|
db=db,
|
|
event_type="CONFIG_CHANGED",
|
|
message=f"Updated backup job '{job.name}' ({job.job_code}).",
|
|
severity="INFO",
|
|
client_id=job.client_id,
|
|
job_id=job.id,
|
|
user_email=admin_user.email
|
|
)
|
|
|
|
return job
|
|
|
|
@router.delete("/{job_id}")
|
|
async def delete_job(
|
|
job_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
admin_user: User = Depends(require_admin)
|
|
):
|
|
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
|
job = res.scalar_one_or_none()
|
|
if not job:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
|
|
|
await db.delete(job)
|
|
await db.commit()
|
|
|
|
await log_event(
|
|
db=db,
|
|
event_type="JOB_DELETED",
|
|
message=f"Deleted backup job {job.job_code}.",
|
|
severity="WARNING",
|
|
client_id=job.client_id,
|
|
user_email=admin_user.email
|
|
)
|
|
|
|
return {"message": f"Job {job.job_code} deleted"}
|
|
|
|
@router.post("/{job_id}/trigger")
|
|
async def trigger_job(
|
|
job_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Notifies the connected agent to start executing this backup job immediately."""
|
|
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
|
job = res.scalar_one_or_none()
|
|
if not job:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
|
|
|
job.status = "QUEUED"
|
|
await db.commit()
|
|
|
|
# Broadcast event so the agent / web UI knows the job was triggered
|
|
await ws_manager.broadcast("JOB_TRIGGERED", {
|
|
"job_id": job.id,
|
|
"job_code": job.job_code,
|
|
"client_id": job.client_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
})
|
|
|
|
return {"message": f"Job {job.job_code} triggered"}
|