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

@@ -12,6 +12,7 @@ from .operatingsystems import operatingsystems_bp
from .dashboard import dashboard_bp
from .dashboarddefaults import dashboarddefaults_bp
from .applications import applications_bp
from .supportteams import supportteams_bp
from .search import search_bp
from .reports import reports_bp
from .collector import collector_bp
@@ -35,6 +36,7 @@ __all__ = [
'dashboard_bp',
'dashboarddefaults_bp',
'applications_bp',
'supportteams_bp',
'search_bp',
'reports_bp',
'collector_bp',

View File

@@ -5,7 +5,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
Application, AppVersion, AppOwner, SupportTeam, AuditLog
Application, AppVersion, AuditLog
)
from shopdb.utils.responses import (
success_response,
@@ -96,20 +96,8 @@ def list_applications():
items, total = paginate_query(query, page, per_page)
data = []
for app in items:
# to_dict already flattens supportteamname/teamurl/contacts.
app_dict = app.to_dict()
if app.supportteam:
app_dict['supportteam'] = {
'supportteamid': app.supportteam.supportteamid,
'teamname': app.supportteam.teamname,
'teamurl': app.supportteam.teamurl,
'owner': {
'appownerid': app.supportteam.owner.appownerid,
'appowner': app.supportteam.owner.appowner,
'sso': app.supportteam.owner.sso
} if app.supportteam.owner else None
}
else:
app_dict['supportteam'] = None
app_dict['installedcount'] = _installed_count(app.appid)
data.append(app_dict)
@@ -126,19 +114,6 @@ def get_application(app_id: int):
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
data = app.to_dict()
if app.supportteam:
data['supportteam'] = {
'supportteamid': app.supportteam.supportteamid,
'teamname': app.supportteam.teamname,
'teamurl': app.supportteam.teamurl,
'owner': {
'appownerid': app.supportteam.owner.appownerid,
'appowner': app.supportteam.owner.appowner,
'sso': app.supportteam.owner.sso
} if app.supportteam.owner else None
}
else:
data['supportteam'] = None
data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()]
data['installedcount'] = _installed_count(app.appid)
@@ -466,69 +441,5 @@ def update_installed_app(machine_id: int, app_id: int):
return success_response(installed.to_dict(), message='Installation updated')
# ---- Support Teams ----
@applications_bp.route('/supportteams', methods=['GET'])
@jwt_required(optional=True)
def list_support_teams():
"""List all support teams."""
teams = SupportTeam.query.filter_by(isactive=True).order_by(SupportTeam.teamname).all()
data = []
for team in teams:
team_dict = team.to_dict()
team_dict['owner'] = team.owner.appowner if team.owner else None
data.append(team_dict)
return success_response(data)
@applications_bp.route('/supportteams', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_support_team():
"""Create a new support team."""
data = request.get_json()
if not data or not data.get('teamname'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'teamname is required')
team = SupportTeam(
teamname=data['teamname'],
teamurl=data.get('teamurl'),
appownerid=data.get('appownerid')
)
db.session.add(team)
db.session.commit()
return success_response(team.to_dict(), message='Support team created', http_code=201)
# ---- App Owners ----
@applications_bp.route('/appowners', methods=['GET'])
@jwt_required(optional=True)
def list_app_owners():
"""List all application owners."""
owners = AppOwner.query.filter_by(isactive=True).order_by(AppOwner.appowner).all()
return success_response([o.to_dict() for o in owners])
@applications_bp.route('/appowners', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_app_owner():
"""Create a new application owner."""
data = request.get_json()
if not data or not data.get('appowner'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'appowner is required')
owner = AppOwner(
appowner=data['appowner'],
sso=data.get('sso'),
email=data.get('email')
)
db.session.add(owner)
db.session.commit()
return success_response(owner.to_dict(), message='App owner created', http_code=201)
# Support teams + contacts now live in the supportteams blueprint
# (/api/supportteams), replacing the legacy appowners pair.

View File

@@ -0,0 +1,219 @@
"""Support Teams API endpoints - teams and their nested contacts."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
SupportTeam, SupportTeamContact, Application, AuditLog
)
from shopdb.utils.responses import (
success_response,
error_response,
ErrorCodes
)
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
supportteams_bp = Blueprint('supportteams', __name__)
@supportteams_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_support_teams():
"""List support teams (with contacts). ?active and ?teamname filters."""
query = SupportTeam.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(SupportTeam.isactive == True)
# Exact-match natural-key lookup for idempotent import (team name).
if exactteamname := request.args.get('teamname'):
query = query.filter(SupportTeam.teamname == exactteamname)
teams = query.order_by(SupportTeam.teamname).all()
return success_response([team.to_dict() for team in teams])
@supportteams_bp.route('/<int:team_id>', methods=['GET'])
@jwt_required(optional=True)
def get_support_team(team_id: int):
"""Get a single support team with its contacts."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
return success_response(team.to_dict())
@supportteams_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_support_team():
"""Create a new support team."""
data = request.get_json()
if not data or not data.get('teamname'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'teamname is required')
if SupportTeam.query.filter_by(teamname=data['teamname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Support team '{data['teamname']}' already exists",
http_code=409)
team = SupportTeam(
teamname=data['teamname'],
teamurl=data.get('teamurl'),
isactive=data.get('isactive', True))
db.session.add(team)
apply_import_timestamps(team, data)
db.session.flush()
AuditLog.log('created', 'SupportTeam', entityid=team.supportteamid,
entityname=team.teamname)
db.session.commit()
return success_response(team.to_dict(), message='Support team created',
http_code=201)
@supportteams_bp.route('/<int:team_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_support_team(team_id: int):
"""Update a support team."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'teamname' in data and data['teamname'] != team.teamname:
if SupportTeam.query.filter_by(teamname=data['teamname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Support team '{data['teamname']}' already exists",
http_code=409)
for key in ['teamname', 'teamurl', 'isactive']:
if key in data:
setattr(team, key, data[key])
apply_import_timestamps(team, data)
AuditLog.log('updated', 'SupportTeam', entityid=team.supportteamid,
entityname=team.teamname)
db.session.commit()
return success_response(team.to_dict(), message='Support team updated')
@supportteams_bp.route('/<int:team_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_support_team(team_id: int):
"""Delete a support team; 409 while any application references it."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
refcount = Application.query.filter_by(supportteamid=team_id).count()
if refcount:
return error_response(
ErrorCodes.CONFLICT,
f'{refcount} application(s) still reference this support team',
http_code=409)
AuditLog.log('deleted', 'SupportTeam', entityid=team.supportteamid,
entityname=team.teamname)
db.session.delete(team) # cascade removes its contacts
db.session.commit()
return success_response(message='Support team deleted')
# ---- Contacts (nested under a team) ----
@supportteams_bp.route('/<int:team_id>/contacts', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_contact(team_id: int):
"""Add a contact to a support team."""
team = db.session.get(SupportTeam, team_id)
if not team:
return error_response(ErrorCodes.NOT_FOUND, 'Support team not found',
http_code=404)
data = request.get_json()
if not data or not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
contact = SupportTeamContact(
supportteamid=team_id,
name=data['name'],
sso=data.get('sso'),
sortorder=data.get('sortorder', 0),
isactive=data.get('isactive', True))
db.session.add(contact)
apply_import_timestamps(contact, data)
db.session.flush()
AuditLog.log('created', 'SupportTeamContact', entityid=contact.contactid,
entityname=contact.name)
db.session.commit()
return success_response(contact.to_dict(), message='Contact created',
http_code=201)
@supportteams_bp.route('/<int:team_id>/contacts/<int:contact_id>',
methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_contact(team_id: int, contact_id: int):
"""Update a support-team contact."""
contact = SupportTeamContact.query.filter_by(
contactid=contact_id, supportteamid=team_id).first()
if not contact:
return error_response(ErrorCodes.NOT_FOUND, 'Contact not found',
http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['name', 'sso', 'sortorder', 'isactive']:
if key in data:
setattr(contact, key, data[key])
apply_import_timestamps(contact, data)
AuditLog.log('updated', 'SupportTeamContact', entityid=contact.contactid,
entityname=contact.name)
db.session.commit()
return success_response(contact.to_dict(), message='Contact updated')
@supportteams_bp.route('/<int:team_id>/contacts/<int:contact_id>',
methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_contact(team_id: int, contact_id: int):
"""Delete a support-team contact."""
contact = SupportTeamContact.query.filter_by(
contactid=contact_id, supportteamid=team_id).first()
if not contact:
return error_response(ErrorCodes.NOT_FOUND, 'Contact not found',
http_code=404)
AuditLog.log('deleted', 'SupportTeamContact', entityid=contact.contactid,
entityname=contact.name)
db.session.delete(contact)
db.session.commit()
return success_response(message='Contact deleted')