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,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}>"