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:
388
backend/alembic/versions/853ebea730ab_add_target_lists_table.py
Normal file
388
backend/alembic/versions/853ebea730ab_add_target_lists_table.py
Normal 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 ###
|
||||
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}
|
||||
107
backend/tests/test_target_lists.py
Normal file
107
backend/tests/test_target_lists.py
Normal 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
|
||||
Reference in New Issue
Block a user