-
Support Information
+
Support
-
- App Owner
- {{ app.supportteam?.owner?.appowner || '-' }}
-
-
-
SSO
-
{{ app.supportteam.owner.sso }}
+
+ Contacts
+
+
+ {{ contact.name }} ({{ contact.sso }})
+
+
@@ -326,6 +326,11 @@ function handleImageError(e) {
font-size: 1.125rem;
}
+/* Support contacts stack one per line */
+.contact-line {
+ display: block;
+}
+
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
.notes-text {
white-space: pre-wrap;
diff --git a/frontend/src/views/applications/ApplicationForm.vue b/frontend/src/views/applications/ApplicationForm.vue
index 17e6371..38f6331 100644
--- a/frontend/src/views/applications/ApplicationForm.vue
+++ b/frontend/src/views/applications/ApplicationForm.vue
@@ -154,7 +154,7 @@
+
+
diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js
index cb77cf2..5939cd7 100644
--- a/frontend/src/views/settings/settingsNav.js
+++ b/frontend/src/views/settings/settingsNav.js
@@ -22,6 +22,7 @@ export const settingsGroups = [
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
+ { to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
],
},
{
diff --git a/migrations/versions/7d18_supportteamcontacts.py b/migrations/versions/7d18_supportteamcontacts.py
new file mode 100644
index 0000000..74fdc31
--- /dev/null
+++ b/migrations/versions/7d18_supportteamcontacts.py
@@ -0,0 +1,150 @@
+"""Support teams get contacts; drop the legacy appowners pair
+
+Restructures the application support model: supportteams keeps teamname (now
+unique) + teamurl (widened to TEXT for ServiceNow group deep links), gains a
+child supportteamcontacts table, and sheds its single-owner appownerid FK and
+the appowners table. Each legacy team's appowner is migrated into ONE contact.
+applications.supportteamid is unchanged (it already points at supportteams).
+
+Idempotent guards throughout so it is safe on a partially-migrated box.
+
+Revision ID: 7d18_supportteamcontacts
+Revises: 7d17_machines_rename
+Create Date: 2026-07-11
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = '7d18_supportteamcontacts'
+down_revision = '7d17_machines_rename'
+branch_labels = None
+depends_on = None
+
+
+def _has_table(insp, name):
+ return insp.has_table(name)
+
+
+def _has_column(insp, table, column):
+ return column in [c['name'] for c in insp.get_columns(table)]
+
+
+def _has_unique(insp, table, name):
+ return name in [u['name'] for u in insp.get_unique_constraints(table)]
+
+
+def upgrade():
+ bind = op.get_bind()
+ insp = sa.inspect(bind)
+
+ # 1. New contacts table.
+ if not _has_table(insp, 'supportteamcontacts'):
+ op.create_table(
+ 'supportteamcontacts',
+ sa.Column('contactid', sa.Integer(), primary_key=True),
+ sa.Column('supportteamid', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('sso', sa.String(length=50), nullable=True),
+ sa.Column('sortorder', sa.Integer(), nullable=False,
+ server_default='0'),
+ sa.Column('createddate', sa.DateTime(), nullable=False),
+ sa.Column('modifieddate', sa.DateTime(), nullable=False),
+ sa.Column('isactive', sa.Boolean(), nullable=False,
+ server_default='1'),
+ sa.ForeignKeyConstraint(
+ ['supportteamid'], ['supportteams.supportteamid'],
+ ondelete='CASCADE'),
+ )
+
+ # 2. Migrate each team's appowner into one contact.
+ if _has_table(insp, 'appowners') and \
+ _has_column(insp, 'supportteams', 'appownerid'):
+ bind.exec_driver_sql(
+ "INSERT INTO supportteamcontacts "
+ "(supportteamid, name, sso, sortorder, isactive, "
+ " createddate, modifieddate) "
+ "SELECT st.supportteamid, ao.appowner, ao.sso, 0, 1, "
+ " NOW(), NOW() "
+ "FROM supportteams st "
+ "JOIN appowners ao ON st.appownerid = ao.appownerid "
+ "WHERE st.appownerid IS NOT NULL")
+
+ # 3. teamname becomes unique.
+ if not _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'):
+ op.create_unique_constraint(
+ 'uq_supportteams_teamname', 'supportteams', ['teamname'])
+
+ # 4. Widen teamurl to TEXT.
+ op.alter_column('supportteams', 'teamurl',
+ existing_type=sa.String(length=255),
+ type_=sa.Text(), existing_nullable=True)
+
+ # 5. Drop the appownerid FK + column, then the appowners table.
+ if _has_column(insp, 'supportteams', 'appownerid'):
+ for fk in insp.get_foreign_keys('supportteams'):
+ if 'appownerid' in fk['constrained_columns'] and fk.get('name'):
+ bind.exec_driver_sql(
+ f"ALTER TABLE supportteams DROP FOREIGN KEY {fk['name']}")
+ op.drop_column('supportteams', 'appownerid')
+ bind.exec_driver_sql("DROP TABLE IF EXISTS appowners")
+
+
+def downgrade():
+ bind = op.get_bind()
+ insp = sa.inspect(bind)
+
+ # Recreate appowners.
+ if not _has_table(insp, 'appowners'):
+ op.create_table(
+ 'appowners',
+ sa.Column('appownerid', sa.Integer(), primary_key=True),
+ sa.Column('appowner', sa.String(length=100), nullable=False),
+ sa.Column('sso', sa.String(length=50), nullable=True),
+ sa.Column('email', sa.String(length=100), nullable=True),
+ sa.Column('createddate', sa.DateTime(), nullable=False),
+ sa.Column('modifieddate', sa.DateTime(), nullable=False),
+ sa.Column('isactive', sa.Boolean(), nullable=False,
+ server_default='1'),
+ )
+
+ # Re-add the appownerid FK column.
+ if not _has_column(insp, 'supportteams', 'appownerid'):
+ op.add_column('supportteams',
+ sa.Column('appownerid', sa.Integer(), nullable=True))
+ op.create_foreign_key(
+ 'fk_supportteams_appownerid', 'supportteams', 'appowners',
+ ['appownerid'], ['appownerid'])
+
+ # Best-effort data reversal: each team's first active contact -> an owner.
+ if _has_table(insp, 'supportteamcontacts'):
+ rows = bind.execute(sa.text(
+ "SELECT supportteamid, name, sso FROM supportteamcontacts "
+ "WHERE isactive = 1 ORDER BY supportteamid, sortorder, contactid"))
+ seen = set()
+ for row in rows:
+ if row.supportteamid in seen:
+ continue
+ seen.add(row.supportteamid)
+ result = bind.execute(sa.text(
+ "INSERT INTO appowners "
+ "(appowner, sso, isactive, createddate, modifieddate) "
+ "VALUES (:name, :sso, 1, NOW(), NOW())"),
+ {'name': row.name, 'sso': row.sso})
+ bind.execute(sa.text(
+ "UPDATE supportteams SET appownerid = :ownerid "
+ "WHERE supportteamid = :teamid"),
+ {'ownerid': result.lastrowid, 'teamid': row.supportteamid})
+
+ # Narrow teamurl back to VARCHAR(255).
+ op.alter_column('supportteams', 'teamurl',
+ existing_type=sa.Text(),
+ type_=sa.String(length=255), existing_nullable=True)
+
+ # Drop the unique constraint.
+ if _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'):
+ op.drop_constraint('uq_supportteams_teamname', 'supportteams',
+ type_='unique')
+
+ op.drop_table('supportteamcontacts')
diff --git a/shopdb/__init__.py b/shopdb/__init__.py
index 834b15f..1ed4107 100644
--- a/shopdb/__init__.py
+++ b/shopdb/__init__.py
@@ -114,6 +114,7 @@ CORE_BLUEPRINT_NAMES = (
'dashboard',
'dashboarddefaults',
'applications',
+ 'supportteams',
'search',
'reports',
'collector',
diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py
index 4743b1b..80bc8bb 100644
--- a/shopdb/core/api/__init__.py
+++ b/shopdb/core/api/__init__.py
@@ -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',
diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py
index e411896..9a5bc78 100644
--- a/shopdb/core/api/applications.py
+++ b/shopdb/core/api/applications.py
@@ -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.
diff --git a/shopdb/core/api/supportteams.py b/shopdb/core/api/supportteams.py
new file mode 100644
index 0000000..918d5cd
--- /dev/null
+++ b/shopdb/core/api/supportteams.py
@@ -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('/
', 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('/', 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('/', 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('//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('//contacts/',
+ 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('//contacts/',
+ 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')
diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py
index 7605cea..c64c9bd 100644
--- a/shopdb/core/models/__init__.py
+++ b/shopdb/core/models/__init__.py
@@ -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',
diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py
index f7f3b5f..5aebb31 100644
--- a/shopdb/core/models/application.py
+++ b/shopdb/core/models/application.py
@@ -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""
-
-
-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 / 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""
diff --git a/shopdb/core/models/supportteam.py b/shopdb/core/models/supportteam.py
new file mode 100644
index 0000000..2730a57
--- /dev/null
+++ b/shopdb/core/models/supportteam.py
@@ -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""
+
+
+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""
diff --git a/tests/test_core/test_supportteams.py b/tests/test_core/test_supportteams.py
new file mode 100644
index 0000000..97e9367
--- /dev/null
+++ b/tests/test_core/test_supportteams.py
@@ -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