indie-status-page/app/api/services.py
2026-04-25 12:14:06 +00:00

156 lines
No EOL
4.9 KiB
Python

"""Services API endpoints with tier enforcement.
Provides both admin API-key endpoints (no org context) and
organization-scoped endpoints with tier enforcement.
When X-Organization-ID header is provided with a valid API key,
tier enforcement is applied to creation endpoints.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, verify_api_key
from app.models.models import Service
from app.models.saas_models import Organization
from app.services.tier_enforcement import (
enforce_service_limit,
get_org_if_provided,
)
router = APIRouter()
class ServiceCreate(BaseModel):
name: str = Field(..., max_length=100)
slug: str = Field(..., max_length=50, pattern=r"^[a-z0-9-]+$")
description: str | None = None
group_name: str | None = Field(None, max_length=50)
position: int = 0
is_visible: bool = True
organization_id: str | None = None
class ServiceUpdate(BaseModel):
name: str | None = None
slug: str | None = Field(None, max_length=50, pattern=r"^[a-z0-9-]+$")
description: str | None = None
group_name: str | None = None
position: int | None = None
is_visible: bool | None = None
def serialize_service(s: Service) -> dict:
return {
"id": s.id,
"name": s.name,
"slug": s.slug,
"description": s.description,
"group_name": s.group_name,
"position": s.position,
"is_visible": s.is_visible,
"organization_id": s.organization_id,
"created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
@router.get("/")
async def list_services(db: AsyncSession = Depends(get_db)):
"""List all services."""
result = await db.execute(select(Service).order_by(Service.position, Service.name))
services = result.scalars().all()
return [serialize_service(s) for s in services]
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_service(
data: ServiceCreate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Create a new service.
If organization_id is provided in the request body and a matching org
exists, tier enforcement is applied to ensure the org hasn't exceeded
its services_per_page limit.
"""
org = None
if data.organization_id:
result = await db.execute(
select(Organization).where(Organization.id == data.organization_id)
)
org = result.scalar_one_or_none()
if org is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization '{data.organization_id}' not found",
)
# Tier enforcement when org context is provided
if org is not None:
await enforce_service_limit(db, org)
service = Service(
name=data.name,
slug=data.slug,
description=data.description,
group_name=data.group_name,
position=data.position,
is_visible=data.is_visible,
organization_id=data.organization_id,
)
db.add(service)
await db.flush()
await db.refresh(service)
return serialize_service(service)
@router.get("/{service_id}")
async def get_service(service_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get a service by ID."""
result = await db.execute(select(Service).where(Service.id == str(service_id)))
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
return serialize_service(service)
@router.patch("/{service_id}")
async def update_service(
service_id: UUID,
data: ServiceUpdate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Update a service."""
result = await db.execute(select(Service).where(Service.id == str(service_id)))
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(service, field, value)
await db.flush()
await db.refresh(service)
return serialize_service(service)
@router.delete("/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_service(
service_id: UUID,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Delete a service."""
result = await db.execute(select(Service).where(Service.id == str(service_id)))
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
await db.delete(service)