Add application support teams with contacts
All checks were successful
CI / backend (push) Successful in 1m7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

Replaces the legacy supportteams/appowners pair: supportteams
(teamname unique, teamurl ServiceNow link) + supportteamcontacts
(multiple named contacts with SSO per team, the people you reach out
to), applications.supportteamid intact. Migration 7d18 migrates each
legacy team owner into a contact, drops appowners, and has a validated
downgrade. New /api/supportteams CRUD (admin writes, import-mode
timestamps, teamname lookup), Support card on application detail,
contacts column on the list, and a settings management page.
IMPORT-API.md mapping updated to the concrete endpoints.

658 tests pass; live dev migration applied (24 teams / 24 contacts);
fresh-install and downgrade round-trips verified on scratch DBs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 20:29:12 -04:00
parent 46e50c07ff
commit 7dae281993
18 changed files with 1095 additions and 172 deletions

View File

@@ -0,0 +1,150 @@
"""Support teams get contacts; drop the legacy appowners pair
Restructures the application support model: supportteams keeps teamname (now
unique) + teamurl (widened to TEXT for ServiceNow group deep links), gains a
child supportteamcontacts table, and sheds its single-owner appownerid FK and
the appowners table. Each legacy team's appowner is migrated into ONE contact.
applications.supportteamid is unchanged (it already points at supportteams).
Idempotent guards throughout so it is safe on a partially-migrated box.
Revision ID: 7d18_supportteamcontacts
Revises: 7d17_machines_rename
Create Date: 2026-07-11
"""
from alembic import op
import sqlalchemy as sa
revision = '7d18_supportteamcontacts'
down_revision = '7d17_machines_rename'
branch_labels = None
depends_on = None
def _has_table(insp, name):
return insp.has_table(name)
def _has_column(insp, table, column):
return column in [c['name'] for c in insp.get_columns(table)]
def _has_unique(insp, table, name):
return name in [u['name'] for u in insp.get_unique_constraints(table)]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
# 1. New contacts table.
if not _has_table(insp, 'supportteamcontacts'):
op.create_table(
'supportteamcontacts',
sa.Column('contactid', sa.Integer(), primary_key=True),
sa.Column('supportteamid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('sso', sa.String(length=50), nullable=True),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default='1'),
sa.ForeignKeyConstraint(
['supportteamid'], ['supportteams.supportteamid'],
ondelete='CASCADE'),
)
# 2. Migrate each team's appowner into one contact.
if _has_table(insp, 'appowners') and \
_has_column(insp, 'supportteams', 'appownerid'):
bind.exec_driver_sql(
"INSERT INTO supportteamcontacts "
"(supportteamid, name, sso, sortorder, isactive, "
" createddate, modifieddate) "
"SELECT st.supportteamid, ao.appowner, ao.sso, 0, 1, "
" NOW(), NOW() "
"FROM supportteams st "
"JOIN appowners ao ON st.appownerid = ao.appownerid "
"WHERE st.appownerid IS NOT NULL")
# 3. teamname becomes unique.
if not _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'):
op.create_unique_constraint(
'uq_supportteams_teamname', 'supportteams', ['teamname'])
# 4. Widen teamurl to TEXT.
op.alter_column('supportteams', 'teamurl',
existing_type=sa.String(length=255),
type_=sa.Text(), existing_nullable=True)
# 5. Drop the appownerid FK + column, then the appowners table.
if _has_column(insp, 'supportteams', 'appownerid'):
for fk in insp.get_foreign_keys('supportteams'):
if 'appownerid' in fk['constrained_columns'] and fk.get('name'):
bind.exec_driver_sql(
f"ALTER TABLE supportteams DROP FOREIGN KEY {fk['name']}")
op.drop_column('supportteams', 'appownerid')
bind.exec_driver_sql("DROP TABLE IF EXISTS appowners")
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
# Recreate appowners.
if not _has_table(insp, 'appowners'):
op.create_table(
'appowners',
sa.Column('appownerid', sa.Integer(), primary_key=True),
sa.Column('appowner', sa.String(length=100), nullable=False),
sa.Column('sso', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default='1'),
)
# Re-add the appownerid FK column.
if not _has_column(insp, 'supportteams', 'appownerid'):
op.add_column('supportteams',
sa.Column('appownerid', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_supportteams_appownerid', 'supportteams', 'appowners',
['appownerid'], ['appownerid'])
# Best-effort data reversal: each team's first active contact -> an owner.
if _has_table(insp, 'supportteamcontacts'):
rows = bind.execute(sa.text(
"SELECT supportteamid, name, sso FROM supportteamcontacts "
"WHERE isactive = 1 ORDER BY supportteamid, sortorder, contactid"))
seen = set()
for row in rows:
if row.supportteamid in seen:
continue
seen.add(row.supportteamid)
result = bind.execute(sa.text(
"INSERT INTO appowners "
"(appowner, sso, isactive, createddate, modifieddate) "
"VALUES (:name, :sso, 1, NOW(), NOW())"),
{'name': row.name, 'sso': row.sso})
bind.execute(sa.text(
"UPDATE supportteams SET appownerid = :ownerid "
"WHERE supportteamid = :teamid"),
{'ownerid': result.lastrowid, 'teamid': row.supportteamid})
# Narrow teamurl back to VARCHAR(255).
op.alter_column('supportteams', 'teamurl',
existing_type=sa.Text(),
type_=sa.String(length=255), existing_nullable=True)
# Drop the unique constraint.
if _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'):
op.drop_constraint('uq_supportteams_teamname', 'supportteams',
type_='unique')
op.drop_table('supportteamcontacts')