Files
Business-Management/backend/app/routers/storage_config.py
T
curo1305 cfec3bb906 feat: Phase 4+5 — admin storage UI, backend proxy, CLAUDE.md enforcement
- backend/app/routers/storage_config.py: 5 admin-only endpoints proxying
  storage-service config + migration API (GET/PATCH/POST/DELETE)
- backend/app/main.py: register storage_config router
- frontend/src/api/client.ts: StorageStatus, MigrationStatus,
  StorageBackendConfig interfaces + 5 API functions
- frontend/src/pages/StorageAdminPage.tsx: full admin UI — backend health
  dot, driver selector (local/S3/WebDAV), conditional credential fields,
  Test & Migrate button, live 2s-poll migration progress bar, Cancel
- frontend/src/App.tsx: /admin/storage route (AdminRoute guard)
- CLAUDE.md: storage enforcement rule, updated Docker tables (6 services,
  3 volumes), §20 in merge checklist
- backend/CLAUDE.md, frontend/CLAUDE.md, doc-service/CLAUDE.md,
  ai-service/CLAUDE.md: updated to reflect storage-service integration
- tests/ALL_TESTS.md + tests/storage-service_tests.md: §20 (20 tests)
- backend/STATUS.md, frontend/STATUS.md: updated with new endpoints/routes
- changelog/2026-04-20_storage-service.md: full change log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:13:05 +02:00

127 lines
4.3 KiB
Python

"""
Admin-only endpoints for storage-service backend configuration.
GET /admin/storage-config — current backend driver + health
PATCH /admin/storage-config — update backend config (no data migration)
POST /admin/storage-config/migrate — start migration to a new backend
GET /admin/storage-config/migrate/status — poll migration progress
DELETE /admin/storage-config/migrate — cancel in-progress migration
All endpoints proxy to storage-service:8020.
"""
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.core.config import settings
from app.deps import get_current_admin
from app.models.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
_STORAGE_BASE = settings.STORAGE_SERVICE_URL
class BackendConfigUpdate(BaseModel):
driver: str
config: dict = {}
class MigrateRequest(BaseModel):
driver: str
config: dict = {}
def _storage_url(path: str) -> str:
return f"{_STORAGE_BASE}{path}"
async def _proxy_get(path: str) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(_storage_url(path))
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="Not found")
resp.raise_for_status()
return resp.json()
@router.get("/storage-config")
async def get_storage_config(
_: User = Depends(get_current_admin),
) -> dict:
"""Return current backend driver and health status."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(_storage_url("/health"))
resp.raise_for_status()
return resp.json()
@router.patch("/storage-config", status_code=204)
async def update_storage_config(
body: BackendConfigUpdate,
_: User = Depends(get_current_admin),
) -> None:
"""
Reconfigure the active backend without migrating data.
Use when changing credentials for the same backend type, or reverting to local.
To move data to a new backend, use POST /admin/storage-config/migrate instead.
"""
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.patch(
_storage_url("/backend-config"),
json={"driver": body.driver, "config": body.config},
)
if resp.status_code == 400:
raise HTTPException(status_code=400, detail=resp.json().get("detail", "Validation failed"))
if resp.status_code == 409:
raise HTTPException(status_code=409, detail="Migration in progress — cannot reconfigure now")
resp.raise_for_status()
@router.post("/storage-config/migrate", status_code=202)
async def start_migration(
body: MigrateRequest,
_: User = Depends(get_current_admin),
) -> dict:
"""
Start an async migration to a new backend.
Flow: validate new backend → copy all objects → verify → switch → delete old objects.
The old backend stays active until 100% of objects are verified on the new one.
Poll GET /admin/storage-config/migrate/status to track progress.
"""
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
_storage_url("/migrate"),
json={"driver": body.driver, "config": body.config},
)
if resp.status_code == 400:
raise HTTPException(status_code=400, detail=resp.json().get("detail", "Validation failed"))
if resp.status_code == 409:
raise HTTPException(status_code=409, detail="A migration is already in progress")
resp.raise_for_status()
return resp.json()
@router.get("/storage-config/migrate/status")
async def migration_status(
_: User = Depends(get_current_admin),
) -> dict:
"""Poll migration progress. State: idle → validating → migrating → switching → cleaning → done."""
return await _proxy_get("/migrate/status")
@router.delete("/storage-config/migrate", status_code=204)
async def cancel_migration(
_: User = Depends(get_current_admin),
) -> None:
"""Cancel a running migration. The old backend remains active."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(_storage_url("/migrate"))
if resp.status_code == 409:
raise HTTPException(status_code=409, detail="No cancellable migration in progress")
resp.raise_for_status()