# 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]}