From 0c2d4ba68595a29c053b594d83545967ec9bbef1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Feb 2026 11:16:40 -0500 Subject: [PATCH] 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 --- .../853ebea730ab_add_target_lists_table.py | 388 ++++++++++++++++++ backend/app/api/endpoints/target_lists.py | 115 ++++++ backend/app/api/router.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/target_list.py | 38 ++ backend/app/schemas/target_list.py | 34 ++ backend/tests/test_target_lists.py | 107 +++++ 7 files changed, 686 insertions(+) create mode 100644 backend/alembic/versions/853ebea730ab_add_target_lists_table.py create mode 100644 backend/app/api/endpoints/target_lists.py create mode 100644 backend/app/models/target_list.py create mode 100644 backend/app/schemas/target_list.py create mode 100644 backend/tests/test_target_lists.py diff --git a/backend/alembic/versions/853ebea730ab_add_target_lists_table.py b/backend/alembic/versions/853ebea730ab_add_target_lists_table.py new file mode 100644 index 00000000..9efaab3f --- /dev/null +++ b/backend/alembic/versions/853ebea730ab_add_target_lists_table.py @@ -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 ### diff --git a/backend/app/api/endpoints/target_lists.py b/backend/app/api/endpoints/target_lists.py new file mode 100644 index 00000000..1b0bab41 --- /dev/null +++ b/backend/app/api/endpoints/target_lists.py @@ -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() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 2e96ddc7..228cce49 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 674f7627..52b28ff3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/target_list.py b/backend/app/models/target_list.py new file mode 100644 index 00000000..f2dbd7ac --- /dev/null +++ b/backend/app/models/target_list.py @@ -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), + ) diff --git a/backend/app/schemas/target_list.py b/backend/app/schemas/target_list.py new file mode 100644 index 00000000..f55e020f --- /dev/null +++ b/backend/app/schemas/target_list.py @@ -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} diff --git a/backend/tests/test_target_lists.py b/backend/tests/test_target_lists.py new file mode 100644 index 00000000..2e10b085 --- /dev/null +++ b/backend/tests/test_target_lists.py @@ -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