feat: add target_lists table, schema, and CRUD endpoints
Introduces the TargetList model (team-scoped JSONB target arrays), Pydantic schemas, and full CRUD REST API at /target-lists/. Includes Alembic migration and 5 integration tests (TDD). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
115
backend/app/api/endpoints/target_lists.py
Normal file
115
backend/app/api/endpoints/target_lists.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Target lists CRUD endpoints."""
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.models.target_list import TargetList
|
||||
from app.models.user import User
|
||||
from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse
|
||||
|
||||
router = APIRouter(prefix="/target-lists", tags=["target-lists"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TargetListResponse])
|
||||
async def list_target_lists(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List all target lists for the current user's team."""
|
||||
if not current_user.team_id:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(TargetList)
|
||||
.where(TargetList.team_id == current_user.team_id)
|
||||
.order_by(TargetList.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=TargetListResponse, status_code=201)
|
||||
async def create_target_list(
|
||||
data: TargetListCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Create a new target list for the current team."""
|
||||
if not current_user.team_id:
|
||||
raise HTTPException(status_code=400, detail="User must belong to a team")
|
||||
target_list = TargetList(
|
||||
team_id=current_user.team_id,
|
||||
created_by=current_user.id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
targets=[t.model_dump() for t in data.targets],
|
||||
)
|
||||
db.add(target_list)
|
||||
await db.commit()
|
||||
await db.refresh(target_list)
|
||||
return target_list
|
||||
|
||||
|
||||
@router.get("/{list_id}", response_model=TargetListResponse)
|
||||
async def get_target_list(
|
||||
list_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
TargetList.id == list_id,
|
||||
TargetList.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
target_list = result.scalar_one_or_none()
|
||||
if not target_list:
|
||||
raise HTTPException(status_code=404, detail="Target list not found")
|
||||
return target_list
|
||||
|
||||
|
||||
@router.put("/{list_id}", response_model=TargetListResponse)
|
||||
async def update_target_list(
|
||||
list_id: UUID,
|
||||
data: TargetListUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
TargetList.id == list_id,
|
||||
TargetList.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
target_list = result.scalar_one_or_none()
|
||||
if not target_list:
|
||||
raise HTTPException(status_code=404, detail="Target list not found")
|
||||
if data.name is not None:
|
||||
target_list.name = data.name
|
||||
if data.description is not None:
|
||||
target_list.description = data.description
|
||||
if data.targets is not None:
|
||||
target_list.targets = [t.model_dump() for t in data.targets]
|
||||
await db.commit()
|
||||
await db.refresh(target_list)
|
||||
return target_list
|
||||
|
||||
|
||||
@router.delete("/{list_id}", status_code=204)
|
||||
async def delete_target_list(
|
||||
list_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TargetList).where(
|
||||
TargetList.id == list_id,
|
||||
TargetList.team_id == current_user.team_id,
|
||||
)
|
||||
)
|
||||
target_list = result.scalar_one_or_none()
|
||||
if not target_list:
|
||||
raise HTTPException(status_code=404, detail="Target list not found")
|
||||
await db.delete(target_list)
|
||||
await db.commit()
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
|
||||
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
|
||||
from app.api.endpoints import ratings, analytics
|
||||
from app.api.endpoints import target_lists
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -28,3 +29,4 @@ api_router.include_router(shared.router) # Public endpoints (no auth)
|
||||
api_router.include_router(tree_markdown.router)
|
||||
api_router.include_router(ratings.router)
|
||||
api_router.include_router(analytics.router)
|
||||
api_router.include_router(target_lists.router)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .account_limit_override import AccountLimitOverride
|
||||
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||
from .platform_setting import PlatformSetting
|
||||
from .user_pinned_tree import UserPinnedTree
|
||||
from .target_list import TargetList
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -55,4 +56,5 @@ __all__ = [
|
||||
"AccountFeatureOverride",
|
||||
"PlatformSetting",
|
||||
"UserPinnedTree",
|
||||
"TargetList",
|
||||
]
|
||||
|
||||
38
backend/app/models/target_list.py
Normal file
38
backend/app/models/target_list.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.team import Team
|
||||
|
||||
|
||||
class TargetList(Base):
|
||||
__tablename__ = "target_lists"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
team_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True
|
||||
)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
# targets: [{"label": "RDS-01", "notes": "optional notes"}, ...]
|
||||
targets: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
34
backend/app/schemas/target_list.py
Normal file
34
backend/app/schemas/target_list.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TargetEntry(BaseModel):
|
||||
label: str = Field(..., min_length=1, max_length=255)
|
||||
notes: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class TargetListCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
targets: list[TargetEntry] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TargetListUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
targets: Optional[list[TargetEntry]] = None
|
||||
|
||||
|
||||
class TargetListResponse(BaseModel):
|
||||
id: UUID
|
||||
team_id: UUID
|
||||
created_by: Optional[UUID]
|
||||
name: str
|
||||
description: Optional[str]
|
||||
targets: list[TargetEntry]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
Reference in New Issue
Block a user