107 lines
3.7 KiB
Python
107 lines
3.7 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""HTTP API for the generic background job service.
|
|
|
|
Endpoints:
|
|
- GET /api/jobs/{jobId} -> single job status
|
|
- GET /api/jobs -> list (filter by jobType, instanceId)
|
|
|
|
Access control: a caller may read a job iff they are a member of its mandate
|
|
(or PlatformAdmin). Jobs without a mandateId (system-wide) are restricted to
|
|
PlatformAdmin only.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
|
|
|
from modules.auth import getRequestContext, RequestContext, limiter
|
|
from modules.serviceCenter.services.serviceBackgroundJobs import (
|
|
getJobStatus,
|
|
listJobs,
|
|
)
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
routeApiMsg = apiRouteContext("routeJobs")
|
|
|
|
router = APIRouter(
|
|
prefix="/api/jobs",
|
|
tags=["BackgroundJobs"],
|
|
responses={404: {"description": "Not found"}},
|
|
)
|
|
|
|
|
|
def _serialiseJob(job: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Strip system audit fields and ensure JSON-safe types."""
|
|
return {k: v for k, v in job.items() if not k.startswith("sys")}
|
|
|
|
|
|
def _userHasMandateAccess(context: RequestContext, mandateId: Optional[str]) -> bool:
|
|
"""Return True if the current user can read jobs for the given mandate scope."""
|
|
if context is None or context.user is None:
|
|
return False
|
|
if context.isPlatformAdmin:
|
|
return True
|
|
if mandateId is None:
|
|
return False
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.datamodels.datamodelMembership import UserMandate
|
|
rootIf = getRootInterface()
|
|
try:
|
|
memberships = rootIf.db.getRecordset(
|
|
UserMandate,
|
|
recordFilter={"userId": context.user.id, "mandateId": mandateId},
|
|
)
|
|
return bool(memberships)
|
|
except Exception as ex:
|
|
logger.warning(
|
|
"Mandate access check failed for user=%s mandate=%s: %s",
|
|
context.user.id, mandateId, ex,
|
|
)
|
|
return False
|
|
|
|
|
|
@router.get("/{jobId}")
|
|
@limiter.limit("60/minute")
|
|
def get_job(
|
|
request: Request,
|
|
jobId: str = Path(..., description="Background job ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> Dict[str, Any]:
|
|
"""Return the current state of one background job."""
|
|
job = getJobStatus(jobId)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail=routeApiMsg("Job not found"))
|
|
if not _userHasMandateAccess(context, job.get("mandateId")):
|
|
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
|
|
return _serialiseJob(job)
|
|
|
|
|
|
@router.get("")
|
|
@limiter.limit("30/minute")
|
|
def list_jobs(
|
|
request: Request,
|
|
jobType: Optional[str] = Query(None),
|
|
mandateId: Optional[str] = Query(None),
|
|
instanceId: Optional[str] = Query(None, description="Feature instance scope"),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> Dict[str, List[Dict[str, Any]]]:
|
|
"""List recent jobs filtered by scope. Newest first."""
|
|
if mandateId is None:
|
|
if not context or not context.isPlatformAdmin:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=routeApiMsg("mandateId is required (only PlatformAdmin may list system-wide)"),
|
|
)
|
|
elif not _userHasMandateAccess(context, mandateId):
|
|
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
|
|
jobs = listJobs(
|
|
mandateId=mandateId,
|
|
featureInstanceId=instanceId,
|
|
jobType=jobType,
|
|
limit=limit,
|
|
)
|
|
return {"items": [_serialiseJob(j) for j in jobs]}
|