feat(l1): add L1 columns + extend account_role CHECK constraint
Adds users.can_cover_l1, accounts.l1_seats_purchased, subscriptions.l1_seat_limit, audit_logs.acting_as. Rotates the users.account_role CHECK constraint to include 'l1_tech' (was: 'owner', 'admin', 'engineer', 'viewer'). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
59
backend/alembic/versions/a8186f22506d_add_l1_columns.py
Normal file
59
backend/alembic/versions/a8186f22506d_add_l1_columns.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""add_l1_columns
|
||||||
|
|
||||||
|
Revision ID: a8186f22506d
|
||||||
|
Revises: b269a1add160
|
||||||
|
Create Date: 2026-05-28 16:15:40.900535
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a8186f22506d'
|
||||||
|
down_revision: Union[str, None] = 'b269a1add160'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'users',
|
||||||
|
sa.Column('can_cover_l1', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'accounts',
|
||||||
|
sa.Column('l1_seats_purchased', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'subscriptions',
|
||||||
|
sa.Column('l1_seat_limit', sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'audit_logs',
|
||||||
|
sa.Column('acting_as', sa.String(30), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rotate account_role CHECK constraint to include 'l1_tech'
|
||||||
|
op.drop_constraint('ck_users_account_role_enum', 'users', type_='check')
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_users_account_role_enum',
|
||||||
|
'users',
|
||||||
|
"account_role IN ('owner', 'admin', 'engineer', 'l1_tech', 'viewer')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse the constraint rotation first
|
||||||
|
op.drop_constraint('ck_users_account_role_enum', 'users', type_='check')
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_users_account_role_enum',
|
||||||
|
'users',
|
||||||
|
"account_role IN ('owner', 'admin', 'engineer', 'viewer')",
|
||||||
|
)
|
||||||
|
op.drop_column('audit_logs', 'acting_as')
|
||||||
|
op.drop_column('subscriptions', 'l1_seat_limit')
|
||||||
|
op.drop_column('accounts', 'l1_seats_purchased')
|
||||||
|
op.drop_column('users', 'can_cover_l1')
|
||||||
@@ -57,6 +57,11 @@ class Account(Base):
|
|||||||
team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# L1 workspace seats
|
||||||
|
l1_seats_purchased: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default="0"
|
||||||
|
)
|
||||||
|
|
||||||
# SSO / SAML groundwork (Task 11)
|
# SSO / SAML groundwork (Task 11)
|
||||||
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc"
|
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class AuditLog(Base):
|
|||||||
)
|
)
|
||||||
details: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
details: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||||
|
acting_as: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
default=lambda: datetime.now(timezone.utc)
|
default=lambda: datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class Subscription(Base):
|
|||||||
billing_interval: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
billing_interval: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="active")
|
status: Mapped[str] = mapped_column(String(50), nullable=False, default="active")
|
||||||
seat_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
seat_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
l1_seat_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
current_period_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
current_period_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
current_period_end: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
current_period_end: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer
|
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -22,7 +22,7 @@ class User(Base):
|
|||||||
name='ck_users_role_enum'
|
name='ck_users_role_enum'
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"account_role IN ('owner', 'admin', 'engineer', 'viewer')",
|
"account_role IN ('owner', 'admin', 'engineer', 'l1_tech', 'viewer')",
|
||||||
name='ck_users_account_role_enum'
|
name='ck_users_account_role_enum'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -50,6 +50,9 @@ class User(Base):
|
|||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||||
|
can_cover_l1: Mapped[bool] = mapped_column(
|
||||||
|
Boolean(), nullable=False, server_default=text('false')
|
||||||
|
)
|
||||||
|
|
||||||
# Legacy team columns (kept for PR A coexistence)
|
# Legacy team columns (kept for PR A coexistence)
|
||||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
|||||||
Reference in New Issue
Block a user