442 lines
13 KiB
Python
442 lines
13 KiB
Python
|
|
"""
|
||
|
|
POI Refresh Jobs API Endpoints
|
||
|
|
|
||
|
|
REST API for managing POI refresh background jobs.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
from sqlalchemy import select, desc
|
||
|
|
from typing import List, Optional
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
import structlog
|
||
|
|
import uuid
|
||
|
|
|
||
|
|
from app.core.database import get_db
|
||
|
|
from app.services.poi_refresh_service import POIRefreshService
|
||
|
|
from app.services.poi_scheduler import get_scheduler
|
||
|
|
from app.models.poi_refresh_job import POIRefreshJob
|
||
|
|
|
||
|
|
logger = structlog.get_logger()
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/poi-refresh-jobs", tags=["POI Refresh Jobs"])
|
||
|
|
|
||
|
|
|
||
|
|
# Response Models
|
||
|
|
class POIRefreshJobResponse(BaseModel):
|
||
|
|
"""POI refresh job response"""
|
||
|
|
id: str
|
||
|
|
tenant_id: str
|
||
|
|
status: str
|
||
|
|
scheduled_at: datetime
|
||
|
|
started_at: Optional[datetime] = None
|
||
|
|
completed_at: Optional[datetime] = None
|
||
|
|
attempt_count: int
|
||
|
|
max_attempts: int
|
||
|
|
pois_detected: Optional[int] = None
|
||
|
|
changes_detected: bool = False
|
||
|
|
change_summary: Optional[dict] = None
|
||
|
|
error_message: Optional[str] = None
|
||
|
|
next_scheduled_at: Optional[datetime] = None
|
||
|
|
duration_seconds: Optional[float] = None
|
||
|
|
is_overdue: bool
|
||
|
|
can_retry: bool
|
||
|
|
|
||
|
|
class Config:
|
||
|
|
from_attributes = True
|
||
|
|
|
||
|
|
|
||
|
|
class ScheduleJobRequest(BaseModel):
|
||
|
|
"""Schedule POI refresh job request"""
|
||
|
|
tenant_id: str = Field(..., description="Tenant UUID")
|
||
|
|
latitude: float = Field(..., ge=-90, le=90, description="Bakery latitude")
|
||
|
|
longitude: float = Field(..., ge=-180, le=180, description="Bakery longitude")
|
||
|
|
scheduled_at: Optional[datetime] = Field(None, description="When to run (default: 180 days from now)")
|
||
|
|
|
||
|
|
|
||
|
|
class JobExecutionResult(BaseModel):
|
||
|
|
"""Job execution result"""
|
||
|
|
status: str
|
||
|
|
job_id: str
|
||
|
|
message: Optional[str] = None
|
||
|
|
pois_detected: Optional[int] = None
|
||
|
|
changes_detected: Optional[bool] = None
|
||
|
|
change_summary: Optional[dict] = None
|
||
|
|
duration_seconds: Optional[float] = None
|
||
|
|
next_scheduled_at: Optional[str] = None
|
||
|
|
error: Optional[str] = None
|
||
|
|
attempt: Optional[int] = None
|
||
|
|
can_retry: Optional[bool] = None
|
||
|
|
|
||
|
|
|
||
|
|
# Endpoints
|
||
|
|
@router.post(
|
||
|
|
"/schedule",
|
||
|
|
response_model=POIRefreshJobResponse,
|
||
|
|
summary="Schedule POI refresh job",
|
||
|
|
description="Schedule a background job to refresh POI context for a tenant"
|
||
|
|
)
|
||
|
|
async def schedule_refresh_job(
|
||
|
|
request: ScheduleJobRequest,
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
Schedule a POI refresh job for a tenant.
|
||
|
|
|
||
|
|
Creates a background job that will detect POIs for the tenant's location
|
||
|
|
at the scheduled time. Default schedule is 180 days from now.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
tenant_uuid = uuid.UUID(request.tenant_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid tenant_id format")
|
||
|
|
|
||
|
|
try:
|
||
|
|
poi_refresh_service = POIRefreshService()
|
||
|
|
job = await poi_refresh_service.schedule_refresh_job(
|
||
|
|
tenant_id=request.tenant_id,
|
||
|
|
latitude=request.latitude,
|
||
|
|
longitude=request.longitude,
|
||
|
|
scheduled_at=request.scheduled_at,
|
||
|
|
session=db
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info(
|
||
|
|
"POI refresh job scheduled via API",
|
||
|
|
tenant_id=request.tenant_id,
|
||
|
|
job_id=str(job.id),
|
||
|
|
scheduled_at=job.scheduled_at
|
||
|
|
)
|
||
|
|
|
||
|
|
return POIRefreshJobResponse(
|
||
|
|
id=str(job.id),
|
||
|
|
tenant_id=str(job.tenant_id),
|
||
|
|
status=job.status,
|
||
|
|
scheduled_at=job.scheduled_at,
|
||
|
|
started_at=job.started_at,
|
||
|
|
completed_at=job.completed_at,
|
||
|
|
attempt_count=job.attempt_count,
|
||
|
|
max_attempts=job.max_attempts,
|
||
|
|
pois_detected=job.pois_detected,
|
||
|
|
changes_detected=job.changes_detected,
|
||
|
|
change_summary=job.change_summary,
|
||
|
|
error_message=job.error_message,
|
||
|
|
next_scheduled_at=job.next_scheduled_at,
|
||
|
|
duration_seconds=job.duration_seconds,
|
||
|
|
is_overdue=job.is_overdue,
|
||
|
|
can_retry=job.can_retry
|
||
|
|
)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(
|
||
|
|
"Failed to schedule POI refresh job",
|
||
|
|
tenant_id=request.tenant_id,
|
||
|
|
error=str(e),
|
||
|
|
exc_info=True
|
||
|
|
)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=500,
|
||
|
|
detail=f"Failed to schedule refresh job: {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
"/{job_id}",
|
||
|
|
response_model=POIRefreshJobResponse,
|
||
|
|
summary="Get refresh job by ID",
|
||
|
|
description="Retrieve details of a specific POI refresh job"
|
||
|
|
)
|
||
|
|
async def get_refresh_job(
|
||
|
|
job_id: str,
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get POI refresh job by ID"""
|
||
|
|
try:
|
||
|
|
job_uuid = uuid.UUID(job_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid job_id format")
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(POIRefreshJob).where(POIRefreshJob.id == job_uuid)
|
||
|
|
)
|
||
|
|
job = result.scalar_one_or_none()
|
||
|
|
|
||
|
|
if not job:
|
||
|
|
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
|
||
|
|
|
||
|
|
return POIRefreshJobResponse(
|
||
|
|
id=str(job.id),
|
||
|
|
tenant_id=str(job.tenant_id),
|
||
|
|
status=job.status,
|
||
|
|
scheduled_at=job.scheduled_at,
|
||
|
|
started_at=job.started_at,
|
||
|
|
completed_at=job.completed_at,
|
||
|
|
attempt_count=job.attempt_count,
|
||
|
|
max_attempts=job.max_attempts,
|
||
|
|
pois_detected=job.pois_detected,
|
||
|
|
changes_detected=job.changes_detected,
|
||
|
|
change_summary=job.change_summary,
|
||
|
|
error_message=job.error_message,
|
||
|
|
next_scheduled_at=job.next_scheduled_at,
|
||
|
|
duration_seconds=job.duration_seconds,
|
||
|
|
is_overdue=job.is_overdue,
|
||
|
|
can_retry=job.can_retry
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
"/tenant/{tenant_id}",
|
||
|
|
response_model=List[POIRefreshJobResponse],
|
||
|
|
summary="Get refresh jobs for tenant",
|
||
|
|
description="Retrieve all POI refresh jobs for a specific tenant"
|
||
|
|
)
|
||
|
|
async def get_tenant_refresh_jobs(
|
||
|
|
tenant_id: str,
|
||
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
||
|
|
limit: int = Query(50, ge=1, le=200, description="Maximum number of results"),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get all POI refresh jobs for a tenant"""
|
||
|
|
try:
|
||
|
|
tenant_uuid = uuid.UUID(tenant_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid tenant_id format")
|
||
|
|
|
||
|
|
query = select(POIRefreshJob).where(POIRefreshJob.tenant_id == tenant_uuid)
|
||
|
|
|
||
|
|
if status:
|
||
|
|
query = query.where(POIRefreshJob.status == status)
|
||
|
|
|
||
|
|
query = query.order_by(desc(POIRefreshJob.scheduled_at)).limit(limit)
|
||
|
|
|
||
|
|
result = await db.execute(query)
|
||
|
|
jobs = result.scalars().all()
|
||
|
|
|
||
|
|
return [
|
||
|
|
POIRefreshJobResponse(
|
||
|
|
id=str(job.id),
|
||
|
|
tenant_id=str(job.tenant_id),
|
||
|
|
status=job.status,
|
||
|
|
scheduled_at=job.scheduled_at,
|
||
|
|
started_at=job.started_at,
|
||
|
|
completed_at=job.completed_at,
|
||
|
|
attempt_count=job.attempt_count,
|
||
|
|
max_attempts=job.max_attempts,
|
||
|
|
pois_detected=job.pois_detected,
|
||
|
|
changes_detected=job.changes_detected,
|
||
|
|
change_summary=job.change_summary,
|
||
|
|
error_message=job.error_message,
|
||
|
|
next_scheduled_at=job.next_scheduled_at,
|
||
|
|
duration_seconds=job.duration_seconds,
|
||
|
|
is_overdue=job.is_overdue,
|
||
|
|
can_retry=job.can_retry
|
||
|
|
)
|
||
|
|
for job in jobs
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
"/{job_id}/execute",
|
||
|
|
response_model=JobExecutionResult,
|
||
|
|
summary="Execute refresh job",
|
||
|
|
description="Manually trigger execution of a pending POI refresh job"
|
||
|
|
)
|
||
|
|
async def execute_refresh_job(
|
||
|
|
job_id: str,
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Manually execute a POI refresh job"""
|
||
|
|
try:
|
||
|
|
job_uuid = uuid.UUID(job_id)
|
||
|
|
except ValueError:
|
||
|
|
raise HTTPException(status_code=400, detail="Invalid job_id format")
|
||
|
|
|
||
|
|
try:
|
||
|
|
poi_refresh_service = POIRefreshService()
|
||
|
|
result = await poi_refresh_service.execute_refresh_job(
|
||
|
|
job_id=job_id,
|
||
|
|
session=db
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info(
|
||
|
|
"POI refresh job executed via API",
|
||
|
|
job_id=job_id,
|
||
|
|
status=result["status"]
|
||
|
|
)
|
||
|
|
|
||
|
|
return JobExecutionResult(**result)
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
raise HTTPException(status_code=404, detail=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(
|
||
|
|
"Failed to execute POI refresh job",
|
||
|
|
job_id=job_id,
|
||
|
|
error=str(e),
|
||
|
|
exc_info=True
|
||
|
|
)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=500,
|
||
|
|
detail=f"Failed to execute refresh job: {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
"/process-pending",
|
||
|
|
summary="Process all pending jobs",
|
||
|
|
description="Manually trigger processing of all pending POI refresh jobs"
|
||
|
|
)
|
||
|
|
async def process_pending_jobs(
|
||
|
|
max_concurrent: int = Query(5, ge=1, le=20, description="Max concurrent executions"),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Process all pending POI refresh jobs"""
|
||
|
|
try:
|
||
|
|
poi_refresh_service = POIRefreshService()
|
||
|
|
result = await poi_refresh_service.process_pending_jobs(
|
||
|
|
max_concurrent=max_concurrent,
|
||
|
|
session=db
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info(
|
||
|
|
"Pending POI refresh jobs processed via API",
|
||
|
|
total_jobs=result["total_jobs"],
|
||
|
|
successful=result["successful"],
|
||
|
|
failed=result["failed"]
|
||
|
|
)
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(
|
||
|
|
"Failed to process pending POI refresh jobs",
|
||
|
|
error=str(e),
|
||
|
|
exc_info=True
|
||
|
|
)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=500,
|
||
|
|
detail=f"Failed to process pending jobs: {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
"/pending",
|
||
|
|
response_model=List[POIRefreshJobResponse],
|
||
|
|
summary="Get pending jobs",
|
||
|
|
description="Retrieve all pending POI refresh jobs that are due for execution"
|
||
|
|
)
|
||
|
|
async def get_pending_jobs(
|
||
|
|
limit: int = Query(100, ge=1, le=500, description="Maximum number of results"),
|
||
|
|
db: AsyncSession = Depends(get_db)
|
||
|
|
):
|
||
|
|
"""Get all pending POI refresh jobs"""
|
||
|
|
try:
|
||
|
|
poi_refresh_service = POIRefreshService()
|
||
|
|
jobs = await poi_refresh_service.get_pending_jobs(
|
||
|
|
limit=limit,
|
||
|
|
session=db
|
||
|
|
)
|
||
|
|
|
||
|
|
return [
|
||
|
|
POIRefreshJobResponse(
|
||
|
|
id=str(job.id),
|
||
|
|
tenant_id=str(job.tenant_id),
|
||
|
|
status=job.status,
|
||
|
|
scheduled_at=job.scheduled_at,
|
||
|
|
started_at=job.started_at,
|
||
|
|
completed_at=job.completed_at,
|
||
|
|
attempt_count=job.attempt_count,
|
||
|
|
max_attempts=job.max_attempts,
|
||
|
|
pois_detected=job.pois_detected,
|
||
|
|
changes_detected=job.changes_detected,
|
||
|
|
change_summary=job.change_summary,
|
||
|
|
error_message=job.error_message,
|
||
|
|
next_scheduled_at=job.next_scheduled_at,
|
||
|
|
duration_seconds=job.duration_seconds,
|
||
|
|
is_overdue=job.is_overdue,
|
||
|
|
can_retry=job.can_retry
|
||
|
|
)
|
||
|
|
for job in jobs
|
||
|
|
]
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(
|
||
|
|
"Failed to get pending POI refresh jobs",
|
||
|
|
error=str(e),
|
||
|
|
exc_info=True
|
||
|
|
)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=500,
|
||
|
|
detail=f"Failed to get pending jobs: {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
"/trigger-scheduler",
|
||
|
|
summary="Trigger scheduler immediately",
|
||
|
|
description="Trigger an immediate check for pending jobs (bypasses schedule)"
|
||
|
|
)
|
||
|
|
async def trigger_scheduler():
|
||
|
|
"""Trigger POI refresh scheduler immediately"""
|
||
|
|
try:
|
||
|
|
scheduler = get_scheduler()
|
||
|
|
|
||
|
|
if not scheduler.is_running:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=503,
|
||
|
|
detail="POI refresh scheduler is not running"
|
||
|
|
)
|
||
|
|
|
||
|
|
result = await scheduler.trigger_immediate_check()
|
||
|
|
|
||
|
|
logger.info(
|
||
|
|
"POI refresh scheduler triggered via API",
|
||
|
|
total_jobs=result["total_jobs"],
|
||
|
|
successful=result["successful"],
|
||
|
|
failed=result["failed"]
|
||
|
|
)
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
except HTTPException:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(
|
||
|
|
"Failed to trigger POI refresh scheduler",
|
||
|
|
error=str(e),
|
||
|
|
exc_info=True
|
||
|
|
)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=500,
|
||
|
|
detail=f"Failed to trigger scheduler: {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
"/scheduler/status",
|
||
|
|
summary="Get scheduler status",
|
||
|
|
description="Check if POI refresh scheduler is running"
|
||
|
|
)
|
||
|
|
async def get_scheduler_status():
|
||
|
|
"""Get POI refresh scheduler status"""
|
||
|
|
try:
|
||
|
|
scheduler = get_scheduler()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"is_running": scheduler.is_running,
|
||
|
|
"check_interval_seconds": scheduler.check_interval_seconds,
|
||
|
|
"max_concurrent_jobs": scheduler.max_concurrent_jobs
|
||
|
|
}
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(
|
||
|
|
"Failed to get scheduler status",
|
||
|
|
error=str(e),
|
||
|
|
exc_info=True
|
||
|
|
)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=500,
|
||
|
|
detail=f"Failed to get scheduler status: {str(e)}"
|
||
|
|
)
|