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:
chihlasm
2026-02-17 11:16:40 -05:00
parent 36e1335a00
commit 0c2d4ba685
7 changed files with 686 additions and 0 deletions

View File

@@ -0,0 +1,388 @@
"""add target_lists table
Revision ID: 853ebea730ab
Revises: 0f1ca2af3647
Create Date: 2026-02-17 11:12:14.598660
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '853ebea730ab'
down_revision: Union[str, None] = '0f1ca2af3647'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('target_lists',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('team_id', sa.UUID(), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('targets', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_target_lists_team_id'), 'target_lists', ['team_id'], unique=False)
op.drop_index(op.f('ix_tree_shares_created_by'), table_name='tree_shares')
op.drop_index(op.f('ix_tree_shares_expires_at'), table_name='tree_shares')
op.drop_index(op.f('ix_tree_shares_share_token'), table_name='tree_shares')
op.drop_index(op.f('ix_tree_shares_tree_id'), table_name='tree_shares')
op.drop_table('tree_shares')
op.drop_table('_team_account_mapping')
op.drop_index(op.f('ix_account_feature_overrides_account_id'), table_name='account_feature_overrides')
op.drop_index(op.f('ix_account_invites_account_id'), table_name='account_invites')
op.drop_index(op.f('ix_account_invites_email'), table_name='account_invites')
op.drop_index(op.f('ix_account_limit_overrides_account_id'), table_name='account_limit_overrides')
op.alter_column('accounts', 'allow_public_shares',
existing_type=sa.BOOLEAN(),
comment='Policy: engineers can create public shares. Only affects NEW shares (grandfathered).',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('attachments', 'uploaded_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_plan_feature_defaults_plan'), table_name='plan_feature_defaults')
op.alter_column('refresh_tokens', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_session_ratings_account_created'), table_name='session_ratings')
op.drop_index(op.f('ix_session_ratings_tree_created'), table_name='session_ratings')
op.alter_column('session_share_views', 'session_id',
existing_type=sa.UUID(),
comment='Denormalized from share for analytics queries',
existing_nullable=False)
op.alter_column('session_share_views', 'viewer_id',
existing_type=sa.UUID(),
comment='NULL for public shares (unauthenticated views)',
existing_nullable=True)
op.alter_column('session_shares', 'account_id',
existing_type=sa.UUID(),
comment='Account that owns this share (denormalized from session at creation)',
existing_nullable=False)
op.alter_column('session_shares', 'share_token',
existing_type=sa.VARCHAR(length=64),
comment='URL-safe random token (48 bytes -> 64 base64 chars)',
existing_nullable=False)
op.alter_column('session_shares', 'share_name',
existing_type=sa.VARCHAR(length=100),
comment="Optional label: 'Training link', 'Customer escalation #1234'",
existing_nullable=True)
op.alter_column('session_shares', 'visibility',
existing_type=sa.VARCHAR(length=20),
comment='public = anyone with link, account = account members only',
existing_nullable=False,
existing_server_default=sa.text("'public'::character varying"))
op.alter_column('session_shares', 'expires_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment='Optional expiration for time-limited shares',
existing_nullable=True)
op.drop_constraint(op.f('session_shares_share_token_key'), 'session_shares', type_='unique')
op.drop_index(op.f('ix_session_shares_share_token'), table_name='session_shares')
op.create_index(op.f('ix_session_shares_share_token'), 'session_shares', ['share_token'], unique=True)
op.alter_column('sessions', 'started_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('sessions', 'completed_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
op.drop_index(op.f('ix_sessions_client_name'), table_name='sessions')
op.drop_index(op.f('ix_sessions_completed'), table_name='sessions')
op.drop_index(op.f('ix_sessions_ticket_number'), table_name='sessions')
op.drop_index(op.f('ix_sessions_tree_snapshot_gin'), table_name='sessions', postgresql_using='gin')
op.drop_index(op.f('ix_step_library_category_id'), table_name='step_library')
op.drop_index(op.f('ix_step_library_created_by'), table_name='step_library')
op.drop_index(op.f('ix_step_library_search'), table_name='step_library', postgresql_using='gin')
op.drop_index(op.f('ix_step_library_tags'), table_name='step_library', postgresql_using='gin')
op.drop_index(op.f('ix_step_library_team_id'), table_name='step_library')
op.drop_index(op.f('ix_step_library_visibility'), table_name='step_library', postgresql_where='(is_active = true)')
op.drop_index(op.f('ix_step_ratings_step_helpful'), table_name='step_ratings')
op.drop_index(op.f('ix_step_ratings_step_id'), table_name='step_ratings')
op.drop_index(op.f('ix_step_ratings_user_id'), table_name='step_ratings')
op.drop_constraint(op.f('uq_step_ratings_step_user'), 'step_ratings', type_='unique')
op.drop_index(op.f('ix_step_usage_log_step_id'), table_name='step_usage_log')
op.drop_index(op.f('ix_step_usage_log_user_step'), table_name='step_usage_log')
op.drop_index(op.f('ix_subscriptions_account_id'), table_name='subscriptions')
op.drop_index(op.f('ix_subscriptions_plan'), table_name='subscriptions')
op.alter_column('teams', 'created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('tree_categories', 'color',
existing_type=sa.VARCHAR(length=7),
comment='Hex color for category dot indicator',
existing_nullable=True,
existing_server_default=sa.text("'#3b82f6'::character varying"))
op.drop_index(op.f('ix_tree_tag_assignments_tag_id'), table_name='tree_tag_assignments')
op.drop_index(op.f('ix_tree_tag_assignments_tree_id'), table_name='tree_tag_assignments')
op.alter_column('trees', 'tree_type',
existing_type=sa.VARCHAR(length=20),
comment='Tree type: troubleshooting (branching decision tree) or procedural (linear step-by-step flow)',
existing_nullable=False,
existing_server_default=sa.text("'troubleshooting'::character varying"))
op.alter_column('trees', 'intake_form',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
comment='Intake form field definitions for procedural flows (JSONB array)',
existing_nullable=True)
op.alter_column('trees', 'created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'fork_reason',
existing_type=sa.VARCHAR(length=255),
comment="Brief reason: 'Added Cisco Meraki steps for our network'",
existing_nullable=True)
op.alter_column('trees', 'parent_updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment="Snapshot of parent's updated_at when fork created. Compare to detect parent updates.",
existing_nullable=True)
op.alter_column('trees', 'root_tree_id',
existing_type=sa.UUID(),
comment='Original tree at root of fork chain (NULL for non-forked trees)',
existing_nullable=True)
op.alter_column('trees', 'fork_depth',
existing_type=sa.INTEGER(),
comment='Fork depth: 0 = original, 1 = direct fork, 2 = fork of fork, etc.',
existing_nullable=False,
existing_server_default=sa.text('0'))
op.drop_index(op.f('idx_trees_fts'), table_name='trees', postgresql_using='gin')
op.drop_index(op.f('ix_trees_fork_analytics'), table_name='trees')
op.drop_index(op.f('ix_user_folder_trees_folder_id'), table_name='user_folder_trees')
op.drop_index(op.f('ix_user_folder_trees_tree_id'), table_name='user_folder_trees')
op.alter_column('user_pinned_trees', 'pinned_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('idx_user_pinned_trees_tree'), table_name='user_pinned_trees')
op.drop_index(op.f('idx_user_pinned_trees_user'), table_name='user_pinned_trees')
op.create_index(op.f('ix_user_pinned_trees_tree_id'), 'user_pinned_trees', ['tree_id'], unique=False)
op.create_index(op.f('ix_user_pinned_trees_user_id'), 'user_pinned_trees', ['user_id'], unique=False)
op.alter_column('users', 'account_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(op.f('fk_users_account_id'), 'users', type_='foreignkey')
op.create_foreign_key(None, 'users', 'accounts', ['account_id'], ['id'], ondelete='RESTRICT')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='foreignkey')
op.create_foreign_key(op.f('fk_users_account_id'), 'users', 'accounts', ['account_id'], ['id'], ondelete='CASCADE')
op.alter_column('users', 'account_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_user_pinned_trees_user_id'), table_name='user_pinned_trees')
op.drop_index(op.f('ix_user_pinned_trees_tree_id'), table_name='user_pinned_trees')
op.create_index(op.f('idx_user_pinned_trees_user'), 'user_pinned_trees', ['user_id'], unique=False)
op.create_index(op.f('idx_user_pinned_trees_tree'), 'user_pinned_trees', ['tree_id'], unique=False)
op.alter_column('user_pinned_trees', 'pinned_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_user_folder_trees_tree_id'), 'user_folder_trees', ['tree_id'], unique=False)
op.create_index(op.f('ix_user_folder_trees_folder_id'), 'user_folder_trees', ['folder_id'], unique=False)
op.create_index(op.f('ix_trees_fork_analytics'), 'trees', ['root_tree_id', 'fork_depth'], unique=False)
op.create_index(op.f('idx_trees_fts'), 'trees', [sa.literal_column("to_tsvector('english'::regconfig, (COALESCE(name, ''::character varying)::text || ' '::text) || COALESCE(description, ''::text))")], unique=False, postgresql_using='gin')
op.alter_column('trees', 'fork_depth',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Fork depth: 0 = original, 1 = direct fork, 2 = fork of fork, etc.',
existing_nullable=False,
existing_server_default=sa.text('0'))
op.alter_column('trees', 'root_tree_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='Original tree at root of fork chain (NULL for non-forked trees)',
existing_nullable=True)
op.alter_column('trees', 'parent_updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment=None,
existing_comment="Snapshot of parent's updated_at when fork created. Compare to detect parent updates.",
existing_nullable=True)
op.alter_column('trees', 'fork_reason',
existing_type=sa.VARCHAR(length=255),
comment=None,
existing_comment="Brief reason: 'Added Cisco Meraki steps for our network'",
existing_nullable=True)
op.alter_column('trees', 'updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('trees', 'intake_form',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
comment=None,
existing_comment='Intake form field definitions for procedural flows (JSONB array)',
existing_nullable=True)
op.alter_column('trees', 'tree_type',
existing_type=sa.VARCHAR(length=20),
comment=None,
existing_comment='Tree type: troubleshooting (branching decision tree) or procedural (linear step-by-step flow)',
existing_nullable=False,
existing_server_default=sa.text("'troubleshooting'::character varying"))
op.create_index(op.f('ix_tree_tag_assignments_tree_id'), 'tree_tag_assignments', ['tree_id'], unique=False)
op.create_index(op.f('ix_tree_tag_assignments_tag_id'), 'tree_tag_assignments', ['tag_id'], unique=False)
op.alter_column('tree_categories', 'color',
existing_type=sa.VARCHAR(length=7),
comment=None,
existing_comment='Hex color for category dot indicator',
existing_nullable=True,
existing_server_default=sa.text("'#3b82f6'::character varying"))
op.alter_column('teams', 'created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_subscriptions_plan'), 'subscriptions', ['plan'], unique=False)
op.create_index(op.f('ix_subscriptions_account_id'), 'subscriptions', ['account_id'], unique=False)
op.create_index(op.f('ix_step_usage_log_user_step'), 'step_usage_log', ['user_id', 'step_id'], unique=False)
op.create_index(op.f('ix_step_usage_log_step_id'), 'step_usage_log', ['step_id'], unique=False)
op.create_unique_constraint(op.f('uq_step_ratings_step_user'), 'step_ratings', ['step_id', 'user_id'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_step_ratings_user_id'), 'step_ratings', ['user_id'], unique=False)
op.create_index(op.f('ix_step_ratings_step_id'), 'step_ratings', ['step_id'], unique=False)
op.create_index(op.f('ix_step_ratings_step_helpful'), 'step_ratings', ['step_id', 'was_helpful'], unique=False)
op.create_index(op.f('ix_step_library_visibility'), 'step_library', ['visibility'], unique=False, postgresql_where='(is_active = true)')
op.create_index(op.f('ix_step_library_team_id'), 'step_library', ['team_id'], unique=False)
op.create_index(op.f('ix_step_library_tags'), 'step_library', ['tags'], unique=False, postgresql_using='gin')
op.create_index(op.f('ix_step_library_search'), 'step_library', [sa.literal_column("to_tsvector('english'::regconfig, title::text)")], unique=False, postgresql_using='gin')
op.create_index(op.f('ix_step_library_created_by'), 'step_library', ['created_by'], unique=False)
op.create_index(op.f('ix_step_library_category_id'), 'step_library', ['category_id'], unique=False)
op.create_index(op.f('ix_sessions_tree_snapshot_gin'), 'sessions', ['tree_snapshot'], unique=False, postgresql_using='gin')
op.create_index(op.f('ix_sessions_ticket_number'), 'sessions', ['ticket_number'], unique=False)
op.create_index(op.f('ix_sessions_completed'), 'sessions', ['completed_at'], unique=False)
op.create_index(op.f('ix_sessions_client_name'), 'sessions', ['client_name'], unique=False)
op.alter_column('sessions', 'completed_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
op.alter_column('sessions', 'started_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_session_shares_share_token'), table_name='session_shares')
op.create_index(op.f('ix_session_shares_share_token'), 'session_shares', ['share_token'], unique=False)
op.create_unique_constraint(op.f('session_shares_share_token_key'), 'session_shares', ['share_token'], postgresql_nulls_not_distinct=False)
op.alter_column('session_shares', 'expires_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment=None,
existing_comment='Optional expiration for time-limited shares',
existing_nullable=True)
op.alter_column('session_shares', 'visibility',
existing_type=sa.VARCHAR(length=20),
comment=None,
existing_comment='public = anyone with link, account = account members only',
existing_nullable=False,
existing_server_default=sa.text("'public'::character varying"))
op.alter_column('session_shares', 'share_name',
existing_type=sa.VARCHAR(length=100),
comment=None,
existing_comment="Optional label: 'Training link', 'Customer escalation #1234'",
existing_nullable=True)
op.alter_column('session_shares', 'share_token',
existing_type=sa.VARCHAR(length=64),
comment=None,
existing_comment='URL-safe random token (48 bytes -> 64 base64 chars)',
existing_nullable=False)
op.alter_column('session_shares', 'account_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='Account that owns this share (denormalized from session at creation)',
existing_nullable=False)
op.alter_column('session_share_views', 'viewer_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='NULL for public shares (unauthenticated views)',
existing_nullable=True)
op.alter_column('session_share_views', 'session_id',
existing_type=sa.UUID(),
comment=None,
existing_comment='Denormalized from share for analytics queries',
existing_nullable=False)
op.create_index(op.f('ix_session_ratings_tree_created'), 'session_ratings', ['tree_id', 'created_at'], unique=False)
op.create_index(op.f('ix_session_ratings_account_created'), 'session_ratings', ['account_id', 'created_at'], unique=False)
op.alter_column('refresh_tokens', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_plan_feature_defaults_plan'), 'plan_feature_defaults', ['plan'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.alter_column('attachments', 'uploaded_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('accounts', 'allow_public_shares',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='Policy: engineers can create public shares. Only affects NEW shares (grandfathered).',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.create_index(op.f('ix_account_limit_overrides_account_id'), 'account_limit_overrides', ['account_id'], unique=False)
op.create_index(op.f('ix_account_invites_email'), 'account_invites', ['email'], unique=False)
op.create_index(op.f('ix_account_invites_account_id'), 'account_invites', ['account_id'], unique=False)
op.create_index(op.f('ix_account_feature_overrides_account_id'), 'account_feature_overrides', ['account_id'], unique=False)
op.create_table('_team_account_mapping',
sa.Column('team_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('account_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('owner_user_id', sa.UUID(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('team_id', name=op.f('_team_account_mapping_pkey'))
)
op.create_table('tree_shares',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tree_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('share_token', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
sa.Column('created_by', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('allow_forking', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], name=op.f('tree_shares_created_by_fkey'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('tree_shares_tree_id_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('tree_shares_pkey')),
sa.UniqueConstraint('share_token', name=op.f('tree_shares_share_token_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_tree_shares_tree_id'), 'tree_shares', ['tree_id'], unique=False)
op.create_index(op.f('ix_tree_shares_share_token'), 'tree_shares', ['share_token'], unique=False)
op.create_index(op.f('ix_tree_shares_expires_at'), 'tree_shares', ['expires_at'], unique=False)
op.create_index(op.f('ix_tree_shares_created_by'), 'tree_shares', ['created_by'], unique=False)
op.drop_index(op.f('ix_target_lists_team_id'), table_name='target_lists')
op.drop_table('target_lists')
# ### end Alembic commands ###

View 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()

View File

@@ -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)

View File

@@ -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",
]

View 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),
)

View 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}

View File

@@ -0,0 +1,107 @@
"""Tests for target lists CRUD."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.models.user import User
from sqlalchemy import select
@pytest.fixture
async def auth_headers(client: AsyncClient, test_db: AsyncSession, test_user: dict):
"""Override auth_headers to ensure the test user has a team_id assigned."""
# Fetch the user from DB and assign a team
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
user = result.scalar_one()
# Create a team and assign the user to it
team = Team(name="Test Team")
test_db.add(team)
await test_db.flush()
user.team_id = team.id
await test_db.commit()
# Re-login to get a fresh token
login_data = {
"email": test_user["email"],
"password": test_user["password"],
}
resp = await client.post("/api/v1/auth/login/json", json=login_data)
assert resp.status_code == 200
token_data = resp.json()
return {"Authorization": f"Bearer {token_data['access_token']}"}
@pytest.mark.asyncio
async def test_create_target_list(client: AsyncClient, auth_headers: dict):
resp = await client.post(
"/api/v1/target-lists/",
json={
"name": "RDS Farm A",
"description": "Production RDS servers",
"targets": [
{"label": "RDS-01", "notes": "192.168.1.10"},
{"label": "RDS-02", "notes": "192.168.1.11"},
],
},
headers=auth_headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["name"] == "RDS Farm A"
assert len(data["targets"]) == 2
@pytest.mark.asyncio
async def test_list_target_lists(client: AsyncClient, auth_headers: dict):
resp = await client.get("/api/v1/target-lists/", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
@pytest.mark.asyncio
async def test_get_target_list(client: AsyncClient, auth_headers: dict):
create = await client.post(
"/api/v1/target-lists/",
json={"name": "Get Test", "targets": [{"label": "SRV-01"}]},
headers=auth_headers,
)
list_id = create.json()["id"]
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["name"] == "Get Test"
@pytest.mark.asyncio
async def test_update_target_list(client: AsyncClient, auth_headers: dict):
create = await client.post(
"/api/v1/target-lists/",
json={"name": "Old Name", "targets": [{"label": "SRV-01"}]},
headers=auth_headers,
)
list_id = create.json()["id"]
resp = await client.put(
f"/api/v1/target-lists/{list_id}",
json={"name": "New Name", "targets": [{"label": "SRV-01"}, {"label": "SRV-02"}]},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
assert len(resp.json()["targets"]) == 2
@pytest.mark.asyncio
async def test_delete_target_list(client: AsyncClient, auth_headers: dict):
create = await client.post(
"/api/v1/target-lists/",
json={"name": "To Delete", "targets": [{"label": "X"}]},
headers=auth_headers,
)
list_id = create.json()["id"]
resp = await client.delete(f"/api/v1/target-lists/{list_id}", headers=auth_headers)
assert resp.status_code == 204
get = await client.get(f"/api/v1/target-lists/{list_id}", headers=auth_headers)
assert get.status_code == 404