artifacts-dashboard/backend/app/api/automations.py
Paweł Orzech f845647934
Some checks failed
Release / release (push) Has been cancelled
Initial release: Artifacts MMO Dashboard & Automation Platform
Full-stack dashboard for controlling, automating, and analyzing
Artifacts MMO characters via the game's HTTP API.

Backend (FastAPI):
- Async Artifacts API client with rate limiting and retry
- 6 automation strategies (combat, gathering, crafting, trading, task, leveling)
- Automation engine with runner, manager, cooldown tracker, pathfinder
- WebSocket relay (game server -> frontend)
- Game data cache, character snapshots, price history, analytics
- 9 API routers, 7 database tables, 3 Alembic migrations
- 108 unit tests

Frontend (Next.js 15 + shadcn/ui):
- Live character dashboard with HP/XP bars and cooldowns
- Character detail with stats, equipment, inventory, skills, manual actions
- Automation management with live log streaming
- Interactive canvas map with content-type coloring and zoom/pan
- Bank management, Grand Exchange with price charts
- Events, logs, analytics pages with Recharts
- WebSocket auto-reconnect with query cache invalidation
- Settings page, error boundaries, dark theme

Infrastructure:
- Docker Compose (dev + prod)
- GitHub Actions CI/CD
- Documentation (Architecture, Automation, Deployment, API)
2026-03-01 19:46:45 +01:00

257 lines
9.1 KiB
Python

import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import async_session_factory
from app.engine.manager import AutomationManager
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.schemas.automation import (
AutomationConfigCreate,
AutomationConfigDetailResponse,
AutomationConfigResponse,
AutomationConfigUpdate,
AutomationLogResponse,
AutomationRunResponse,
AutomationStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/automations", tags=["automations"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_manager(request: Request) -> AutomationManager:
manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None)
if manager is None:
raise HTTPException(
status_code=503,
detail="Automation engine is not available",
)
return manager
# ---------------------------------------------------------------------------
# CRUD -- Automation Configs
# ---------------------------------------------------------------------------
@router.get("/", response_model=list[AutomationConfigResponse])
async def list_configs(request: Request) -> list[AutomationConfigResponse]:
"""List all automation configurations with their current status."""
async with async_session_factory() as db:
stmt = select(AutomationConfig).order_by(AutomationConfig.id)
result = await db.execute(stmt)
configs = result.scalars().all()
return [AutomationConfigResponse.model_validate(c) for c in configs]
@router.post("/", response_model=AutomationConfigResponse, status_code=201)
async def create_config(
payload: AutomationConfigCreate,
request: Request,
) -> AutomationConfigResponse:
"""Create a new automation configuration."""
async with async_session_factory() as db:
config = AutomationConfig(
name=payload.name,
character_name=payload.character_name,
strategy_type=payload.strategy_type,
config=payload.config,
)
db.add(config)
await db.commit()
await db.refresh(config)
return AutomationConfigResponse.model_validate(config)
@router.get("/{config_id}", response_model=AutomationConfigDetailResponse)
async def get_config(config_id: int, request: Request) -> AutomationConfigDetailResponse:
"""Get an automation configuration with its run history."""
async with async_session_factory() as db:
stmt = (
select(AutomationConfig)
.options(selectinload(AutomationConfig.runs))
.where(AutomationConfig.id == config_id)
)
result = await db.execute(stmt)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
return AutomationConfigDetailResponse(
config=AutomationConfigResponse.model_validate(config),
runs=[AutomationRunResponse.model_validate(r) for r in config.runs],
)
@router.put("/{config_id}", response_model=AutomationConfigResponse)
async def update_config(
config_id: int,
payload: AutomationConfigUpdate,
request: Request,
) -> AutomationConfigResponse:
"""Update an automation configuration.
Cannot update a configuration that has an active runner.
"""
manager = _get_manager(request)
if manager.is_running(config_id):
raise HTTPException(
status_code=409,
detail="Cannot update a config while its automation is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
if payload.name is not None:
config.name = payload.name
if payload.config is not None:
config.config = payload.config
if payload.enabled is not None:
config.enabled = payload.enabled
await db.commit()
await db.refresh(config)
return AutomationConfigResponse.model_validate(config)
@router.delete("/{config_id}", status_code=204)
async def delete_config(config_id: int, request: Request) -> None:
"""Delete an automation configuration and all its runs/logs.
Cannot delete a configuration that has an active runner.
"""
manager = _get_manager(request)
if manager.is_running(config_id):
raise HTTPException(
status_code=409,
detail="Cannot delete a config while its automation is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
await db.delete(config)
await db.commit()
# ---------------------------------------------------------------------------
# Control -- Start / Stop / Pause / Resume
# ---------------------------------------------------------------------------
@router.post("/{config_id}/start", response_model=AutomationRunResponse)
async def start_automation(config_id: int, request: Request) -> AutomationRunResponse:
"""Start an automation from its configuration."""
manager = _get_manager(request)
try:
return await manager.start(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{config_id}/stop", status_code=204)
async def stop_automation(config_id: int, request: Request) -> None:
"""Stop a running automation."""
manager = _get_manager(request)
try:
await manager.stop(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{config_id}/pause", status_code=204)
async def pause_automation(config_id: int, request: Request) -> None:
"""Pause a running automation."""
manager = _get_manager(request)
try:
await manager.pause(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{config_id}/resume", status_code=204)
async def resume_automation(config_id: int, request: Request) -> None:
"""Resume a paused automation."""
manager = _get_manager(request)
try:
await manager.resume(config_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# Status & Logs
# ---------------------------------------------------------------------------
@router.get("/status/all", response_model=list[AutomationStatusResponse])
async def get_all_statuses(request: Request) -> list[AutomationStatusResponse]:
"""Get live status for all active automations."""
manager = _get_manager(request)
return manager.get_all_statuses()
@router.get("/{config_id}/status", response_model=AutomationStatusResponse)
async def get_automation_status(
config_id: int,
request: Request,
) -> AutomationStatusResponse:
"""Get live status for a specific automation."""
manager = _get_manager(request)
status = manager.get_status(config_id)
if status is None:
# Check if the config exists at all
async with async_session_factory() as db:
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
# Config exists but no active runner
return AutomationStatusResponse(
config_id=config_id,
character_name=config.character_name,
strategy_type=config.strategy_type,
status="stopped",
)
return status
@router.get("/{config_id}/logs", response_model=list[AutomationLogResponse])
async def get_logs(
config_id: int,
request: Request,
limit: int = 100,
) -> list[AutomationLogResponse]:
"""Get recent logs for an automation config (across all its runs)."""
async with async_session_factory() as db:
# Verify config exists
config = await db.get(AutomationConfig, config_id)
if config is None:
raise HTTPException(status_code=404, detail="Automation config not found")
# Fetch logs for all runs belonging to this config
stmt = (
select(AutomationLog)
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
.where(AutomationRun.config_id == config_id)
.order_by(AutomationLog.created_at.desc())
.limit(min(limit, 500))
)
result = await db.execute(stmt)
logs = result.scalars().all()
return [AutomationLogResponse.model_validate(log) for log in logs]