Add application support teams with contacts
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:
@@ -114,6 +114,7 @@ CORE_BLUEPRINT_NAMES = (
|
||||
'dashboard',
|
||||
'dashboarddefaults',
|
||||
'applications',
|
||||
'supportteams',
|
||||
'search',
|
||||
'reports',
|
||||
'collector',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
219
shopdb/core/api/supportteams.py
Normal file
219
shopdb/core/api/supportteams.py
Normal 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')
|
||||
@@ -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',
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
|
||||
61
shopdb/core/models/supportteam.py
Normal file
61
shopdb/core/models/supportteam.py
Normal 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}>"
|
||||
Reference in New Issue
Block a user