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,234 @@
"""Support teams + contacts: team/contact CRUD, lookup, delete-guard, import.
The application/support-team wiring: a team owns ordered contacts, an
application carries one team, and its payload flattens the team name/url +
active contacts so the frontend needs a single call.
"""
IMPORT_HEADER = {'X-Import-Mode': 'true'}
LEGACY_CREATED = '2020-01-05 08:30:00'
LEGACY_MODIFIED = '2021-06-07T14:15:16'
def _import_headers(auth_headers):
merged = dict(auth_headers)
merged.update(IMPORT_HEADER)
return merged
def _create_team(client, auth_headers, teamname='Controls', teamurl=None):
return client.post('/api/supportteams',
json={'teamname': teamname, 'teamurl': teamurl},
headers=auth_headers)
# ---------------------------------------------------------------------------
# Team CRUD
# ---------------------------------------------------------------------------
def test_create_and_get_team(client, db, auth_headers):
"""Create a team, then fetch it back with an (empty) contacts list."""
resp = _create_team(client, auth_headers, 'Controls',
'https://servicenow.example/group/controls')
assert resp.status_code == 201, resp.get_json()
teamid = resp.get_json()['data']['supportteamid']
got = client.get(f'/api/supportteams/{teamid}', headers=auth_headers)
assert got.status_code == 200
data = got.get_json()['data']
assert data['teamname'] == 'Controls'
assert data['teamurl'] == 'https://servicenow.example/group/controls'
assert data['contacts'] == []
def test_create_team_requires_teamname(client, db, auth_headers):
resp = client.post('/api/supportteams', json={}, headers=auth_headers)
assert resp.status_code == 400
def test_create_team_duplicate_conflict(client, db, auth_headers):
_create_team(client, auth_headers, 'Controls')
dup = _create_team(client, auth_headers, 'Controls')
assert dup.status_code == 409
def test_update_team(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
resp = client.put(f'/api/supportteams/{teamid}',
json={'teamname': 'Controls Renamed',
'teamurl': 'https://x.example'},
headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['teamname'] == 'Controls Renamed'
def test_delete_team(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'ToDelete').get_json()['data']['supportteamid']
resp = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
assert resp.status_code == 200
gone = client.get(f'/api/supportteams/{teamid}', headers=auth_headers)
assert gone.status_code == 404
# ---------------------------------------------------------------------------
# teamname exact-match lookup (import recipe) + active filter
# ---------------------------------------------------------------------------
def test_teamname_lookup_filter(client, db, auth_headers):
for name in ('Controls', 'ControlsB', 'Networking'):
_create_team(client, auth_headers, name)
listed = client.get('/api/supportteams?teamname=Controls', headers=auth_headers)
assert listed.status_code == 200
rows = listed.get_json()['data']
assert len(rows) == 1
assert rows[0]['teamname'] == 'Controls'
def test_list_active_filter_hides_inactive(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'Retired').get_json()['data']['supportteamid']
client.put(f'/api/supportteams/{teamid}', json={'isactive': False},
headers=auth_headers)
active = client.get('/api/supportteams', headers=auth_headers)
assert all(t['teamname'] != 'Retired' for t in active.get_json()['data'])
allteams = client.get('/api/supportteams?active=false', headers=auth_headers)
assert any(t['teamname'] == 'Retired' for t in allteams.get_json()['data'])
# ---------------------------------------------------------------------------
# Delete-with-applications 409
# ---------------------------------------------------------------------------
def test_delete_team_with_applications_conflicts(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'InUse').get_json()['data']['supportteamid']
appresp = client.post('/api/applications',
json={'appname': 'DependentApp',
'supportteamid': teamid},
headers=auth_headers)
assert appresp.status_code == 201, appresp.get_json()
conflict = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
assert conflict.status_code == 409
# Still there.
assert client.get(f'/api/supportteams/{teamid}',
headers=auth_headers).status_code == 200
# ---------------------------------------------------------------------------
# Nested contact CRUD + ordering
# ---------------------------------------------------------------------------
def test_contact_crud_and_ordering(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
# Add two contacts out of sort order.
c2 = client.post(f'/api/supportteams/{teamid}/contacts',
json={'name': 'Second', 'sso': '222', 'sortorder': 2},
headers=auth_headers)
assert c2.status_code == 201, c2.get_json()
c1 = client.post(f'/api/supportteams/{teamid}/contacts',
json={'name': 'First', 'sso': '111', 'sortorder': 1},
headers=auth_headers)
assert c1.status_code == 201
# Team now nests active contacts in sortorder.
team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data']
names = [c['name'] for c in team['contacts']]
assert names == ['First', 'Second']
# Update one contact.
contactid = c1.get_json()['data']['contactid']
upd = client.put(f'/api/supportteams/{teamid}/contacts/{contactid}',
json={'name': 'First Updated'}, headers=auth_headers)
assert upd.status_code == 200
assert upd.get_json()['data']['name'] == 'First Updated'
# Delete the other contact.
otherid = c2.get_json()['data']['contactid']
dele = client.delete(f'/api/supportteams/{teamid}/contacts/{otherid}',
headers=auth_headers)
assert dele.status_code == 200
team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data']
assert [c['name'] for c in team['contacts']] == ['First Updated']
def test_contact_requires_name(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
resp = client.post(f'/api/supportteams/{teamid}/contacts', json={},
headers=auth_headers)
assert resp.status_code == 400
def test_delete_team_cascades_contacts(client, db, auth_headers):
from shopdb.core.models import SupportTeamContact
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
client.post(f'/api/supportteams/{teamid}/contacts',
json={'name': 'Someone'}, headers=auth_headers)
client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
assert SupportTeamContact.query.filter_by(supportteamid=teamid).count() == 0
# ---------------------------------------------------------------------------
# Application carries the team + flattened contacts
# ---------------------------------------------------------------------------
def test_application_carries_supportteam_payload(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'Controls',
'https://sn.example/controls').get_json()['data']['supportteamid']
client.post(f'/api/supportteams/{teamid}/contacts',
json={'name': 'Alice', 'sso': 'a01', 'sortorder': 0},
headers=auth_headers)
appresp = client.post('/api/applications',
json={'appname': 'TeamApp', 'supportteamid': teamid},
headers=auth_headers)
appid = appresp.get_json()['data']['appid']
got = client.get(f'/api/applications/{appid}', headers=auth_headers)
data = got.get_json()['data']
assert data['supportteamid'] == teamid
assert data['supportteamname'] == 'Controls'
assert data['teamurl'] == 'https://sn.example/controls'
assert data['contacts'] == [{'name': 'Alice', 'sso': 'a01'}]
# ---------------------------------------------------------------------------
# Import-mode timestamps
# ---------------------------------------------------------------------------
def test_import_mode_preserves_team_timestamps(client, db, auth_headers):
resp = client.post('/api/supportteams',
json={'teamname': 'Legacy',
'createddate': LEGACY_CREATED,
'modifieddate': LEGACY_MODIFIED},
headers=_import_headers(auth_headers))
assert resp.status_code == 201, resp.get_json()
from shopdb.core.models import SupportTeam
team = SupportTeam.query.filter_by(teamname='Legacy').first()
assert team.createddate.year == 2020 and team.createddate.month == 1
assert team.modifieddate.year == 2021
def test_import_mode_preserves_contact_timestamps(client, db, auth_headers):
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
resp = client.post(f'/api/supportteams/{teamid}/contacts',
json={'name': 'LegacyOwner',
'createddate': LEGACY_CREATED,
'modifieddate': LEGACY_MODIFIED},
headers=_import_headers(auth_headers))
assert resp.status_code == 201, resp.get_json()
from shopdb.core.models import SupportTeamContact
contact = SupportTeamContact.query.filter_by(name='LegacyOwner').first()
assert contact.createddate.year == 2020
assert contact.modifieddate.year == 2021
# ---------------------------------------------------------------------------
# Authz (belt-and-suspenders; the sweep in test_authz also covers these)
# ---------------------------------------------------------------------------
def test_member_cannot_create_team(client, db, member_headers):
resp = client.post('/api/supportteams', json={'teamname': 'X'},
headers=member_headers)
assert resp.status_code == 403