diff --git a/backend/alembic/versions/5812e7df852f_add_target_lists_table.py b/backend/alembic/versions/5812e7df852f_add_target_lists_table.py new file mode 100644 index 00000000..353496e9 --- /dev/null +++ b/backend/alembic/versions/5812e7df852f_add_target_lists_table.py @@ -0,0 +1,39 @@ +"""add target_lists table + +Revision ID: 5812e7df852f +Revises: 0f1ca2af3647 +Create Date: 2026-02-17 11:20:42.919564 + +""" +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 = '5812e7df852f' +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: + op.create_table( + 'target_lists', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('targets', postgresql.JSONB(), nullable=False, server_default='[]'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id']) + + +def downgrade() -> None: + op.drop_index('ix_target_lists_team_id', table_name='target_lists') + op.drop_table('target_lists') diff --git a/backend/alembic/versions/853ebea730ab_add_target_lists_table.py b/backend/alembic/versions/853ebea730ab_add_target_lists_table.py deleted file mode 100644 index 9efaab3f..00000000 --- a/backend/alembic/versions/853ebea730ab_add_target_lists_table.py +++ /dev/null @@ -1,388 +0,0 @@ -"""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 index 1b0bab41..247c510e 100644 --- a/backend/app/api/endpoints/target_lists.py +++ b/backend/app/api/endpoints/target_lists.py @@ -85,11 +85,12 @@ async def update_target_list( 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: + update_fields = data.model_fields_set + if "name" in update_fields and 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: + if "description" in update_fields: + target_list.description = data.description # allow setting to None + if "targets" in update_fields and data.targets is not None: target_list.targets = [t.model_dump() for t in data.targets] await db.commit() await db.refresh(target_list) diff --git a/backend/app/schemas/target_list.py b/backend/app/schemas/target_list.py index f55e020f..0016d393 100644 --- a/backend/app/schemas/target_list.py +++ b/backend/app/schemas/target_list.py @@ -18,7 +18,7 @@ class TargetListCreate(BaseModel): class TargetListUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None - targets: Optional[list[TargetEntry]] = None + targets: Optional[list[TargetEntry]] = Field(None, min_length=1) class TargetListResponse(BaseModel): diff --git a/backend/tests/test_target_lists.py b/backend/tests/test_target_lists.py index 2e10b085..a40cfb48 100644 --- a/backend/tests/test_target_lists.py +++ b/backend/tests/test_target_lists.py @@ -105,3 +105,49 @@ async def test_delete_target_list(client: AsyncClient, auth_headers: dict): get = await client.get(f"/api/v1/target-lists/{list_id}", headers=auth_headers) assert get.status_code == 404 + +@pytest.mark.asyncio +async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers: dict, test_db): + """User from team B cannot access team A's list.""" + import uuid + from app.models.team import Team + from app.models.user import User + from app.core.security import get_password_hash + + # Create team A list using existing auth_headers + create = await client.post( + "/api/v1/target-lists/", + json={"name": "Team A List", "targets": [{"label": "SRV-A"}]}, + headers=auth_headers, + ) + assert create.status_code == 201 + list_id = create.json()["id"] + + # Create a separate team B with its own user + team_b = Team(name=f"Team B {uuid.uuid4()}") + test_db.add(team_b) + await test_db.flush() + + user_b = User( + email=f"userb_{uuid.uuid4()}@test.com", + password_hash=get_password_hash("password123"), + name="User B", + is_active=True, + team_id=team_b.id, + role="engineer", + ) + test_db.add(user_b) + await test_db.flush() + + # Get auth token for user B + login = await client.post( + "/api/v1/auth/login/json", + json={"email": user_b.email, "password": "password123"}, + ) + assert login.status_code == 200 + token_b = login.json()["access_token"] + headers_b = {"Authorization": f"Bearer {token_b}"} + + # Team B cannot access Team A's list + resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b) + assert resp.status_code == 404