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

@@ -114,6 +114,7 @@ CORE_BLUEPRINT_NAMES = (
'dashboard',
'dashboarddefaults',
'applications',
'supportteams',
'search',
'reports',
'collector',

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')

View File

@@ -12,7 +12,8 @@ from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType
from .communication import Communication, CommunicationType
from .user import User, Role, Permission
from .application import Application, AppVersion, AppOwner, SupportTeam
from .application import Application, AppVersion
from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting
from .auditlog import AuditLog
from .customfield import CustomField, CustomFieldValue
@@ -49,8 +50,9 @@ __all__ = [
# Applications
'Application',
'AppVersion',
'AppOwner',
# Support teams
'SupportTeam',
'SupportTeamContact',
# Knowledge Base
# Settings
'Setting',

View File

@@ -2,39 +2,8 @@
from shopdb.extensions import db
from .base import BaseModel
class AppOwner(BaseModel):
"""Application owner/contact."""
__tablename__ = 'appowners'
appownerid = db.Column(db.Integer, primary_key=True)
appowner = db.Column(db.String(100), nullable=False)
sso = db.Column(db.String(50))
email = db.Column(db.String(100))
# Relationships
supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic')
def __repr__(self):
return f"<AppOwner {self.appowner}>"
class SupportTeam(BaseModel):
"""Application support team."""
__tablename__ = 'supportteams'
supportteamid = db.Column(db.Integer, primary_key=True)
teamname = db.Column(db.String(100), nullable=False)
teamurl = db.Column(db.String(255))
appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid'))
# Relationships
owner = db.relationship('AppOwner', back_populates='supportteams')
applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic')
def __repr__(self):
return f"<SupportTeam {self.teamname}>"
# SupportTeam / SupportTeamContact live in supportteam.py; imported by the
# models package so the Application.supportteam relationship resolves.
class Application(BaseModel):
@@ -63,6 +32,22 @@ class Application(BaseModel):
supportteam = db.relationship('SupportTeam', back_populates='applications')
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
def to_dict(self):
"""Serialize, flattening the support team + its active contacts.
Emits supportteamname, teamurl, and the team's active contacts so the
frontend needs a single call to render the Support card.
"""
result = super().to_dict()
team = self.supportteam
result['supportteamname'] = team.teamname if team else None
result['teamurl'] = team.teamurl if team else None
result['contacts'] = [
{'name': c.name, 'sso': c.sso}
for c in team.active_contacts()
] if team else []
return result
def __repr__(self):
return f"<Application {self.appname}>"

View File

@@ -0,0 +1,61 @@
"""Application support teams and their contacts.
A support team is who you contact about an application; each team carries an
optional ServiceNow group deep link (teamurl) and a list of named contacts
(the people you actually reach out to, legacy called them "app owners").
Applications point at one team via applications.supportteamid.
"""
from shopdb.extensions import db
from .base import BaseModel
class SupportTeam(BaseModel):
"""A support team an application belongs to."""
__tablename__ = 'supportteams'
supportteamid = db.Column(db.Integer, primary_key=True)
teamname = db.Column(db.String(100), unique=True, nullable=False)
teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable
# Contacts cascade-delete with the team.
contacts = db.relationship(
'SupportTeamContact', back_populates='team',
cascade='all, delete-orphan', lazy='select')
applications = db.relationship(
'Application', back_populates='supportteam', lazy='dynamic')
def active_contacts(self):
"""Return active contacts in sortorder (then contactid) order."""
return sorted(
(c for c in self.contacts if c.isactive),
key=lambda c: (c.sortorder, c.contactid or 0))
def to_dict(self, with_contacts=True):
"""Serialize the team, nesting its active contacts by default."""
result = super().to_dict()
if with_contacts:
result['contacts'] = [c.to_dict() for c in self.active_contacts()]
return result
def __repr__(self):
return f"<SupportTeam {self.teamname}>"
class SupportTeamContact(BaseModel):
"""A person to contact for a support team."""
__tablename__ = 'supportteamcontacts'
contactid = db.Column(db.Integer, primary_key=True)
supportteamid = db.Column(
db.Integer,
db.ForeignKey('supportteams.supportteamid', ondelete='CASCADE'),
nullable=False)
name = db.Column(db.String(100), nullable=False)
sso = db.Column(db.String(50))
sortorder = db.Column(db.Integer, nullable=False, default=0)
team = db.relationship('SupportTeam', back_populates='contacts')
def __repr__(self):
return f"<SupportTeamContact {self.name}>"