diff --git a/CHANGELOG.md b/CHANGELOG.md index d171111..a23bd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ ADR-007 and ADR-002. ### Added +- Application support teams with contacts, replacing the legacy + supportteams/appowners pair. New core `supportteamcontacts` table (multiple + named contacts per team, ordered by `sortorder`); `supportteams` keeps + `teamname` (now unique) and `teamurl` (a ServiceNow group deep link) and + sheds the single-owner `appownerid` FK. New core blueprint at + `/api/supportteams` (team + nested contact CRUD, admin-gated; `?teamname` + exact-match lookup for import; delete a team 409s while any application still + references it). Migration `7d18_supportteamcontacts` migrates each legacy + team's app owner into one contact. Application payloads now flatten + `supportteamname`, `teamurl`, and the team's active `contacts`; a Support + card on the application detail page and a new `settings/supportteams` + management page render them. + - Import mode: a complete, idempotent HTTP migration surface so a script or LLM can import the classic ASP shopdb through the API alone (no direct DB writes). - Contract surface (plugin contract bumped 0.7.0 -> 0.8.0, additive): new diff --git a/docs/IMPORT-API.md b/docs/IMPORT-API.md index 2d39fdd..9d7b300 100644 --- a/docs/IMPORT-API.md +++ b/docs/IMPORT-API.md @@ -107,9 +107,9 @@ lookup-then-upsert loop (see section 5); rerunning any step is safe. 9. Notification types (`notifications/types`) 10. Per-plugin subtypes: computer types (from `pctype`), machine types, printer types, network device types, measuring-tool types - 11. Support teams + support-team contacts - see the DECIDED disposition in - section 4; import these BEFORE applications because - `applications.supportteamid` points at them + 11. Support teams + support-team contacts (`supportteams`, + `supportteams/{id}/contacts` - see section 3.3); import these BEFORE + applications because `applications.supportteamid` points at them 12. Applications (`applications`) and their versions; import legacy `topics` as applications too (KB links to applications, section 3) 2. **Assets, per type** (each creates the core Asset row plus its extension): @@ -211,11 +211,19 @@ Note on communication types: the classic `comstypes.typename` values seeded `communicationtypes.comtype`. They are created by the reference-data seed, not imported per-row. -### 3.3 Applications, topics, installed apps +### 3.3 Support teams, applications, topics, installed apps + +Support teams and their contacts import BEFORE applications, because +`applications.supportteamid` references a team. The legacy `appowners` table +is folded into contacts: each legacy `supportteams` row carries one +`appownerid`, so import that owner as ONE contact on the team (legacy +`appowner` -> `name`, `sso` -> `sso`). | legacy table | target endpoint | field mapping | NK | |---|---|---|---| -| `applications` | `POST /api/applications` | `appname`, `appdescription`, `supportteamid` (remapped, section 4), `isinstallable`, `applicationnotes`, `installpath`, `applicationlink`, `documentationpath`, `ishidden`, `isprinter`, `islicenced`, `image` | `appname` | +| `supportteams` | `POST /api/supportteams` | `teamname`, `teamurl` (ServiceNow group deep link) | `teamname` | +| `appowners` (via each team's `appownerid`) | `POST /api/supportteams/{supportteamid}/contacts` | `appowner` -> `name`, `sso` -> `sso`, `sortorder` (default 0) | (supportteamid, name) | +| `applications` | `POST /api/applications` | `appname`, `appdescription`, `supportteamid` (remap by team `teamname`, GET `/api/supportteams?teamname=...`), `isinstallable`, `applicationnotes`, `installpath`, `applicationlink`, `documentationpath`, `ishidden`, `isprinter`, `islicenced`, `image` | `appname` | | `appversions` | `POST /api/applications/{appid}/versions` | `version`, `releasedate`, `notes` | `version` (per app) | | `topics` | `POST /api/applications` | `topics` is a near-clone of `applications` and `knowledgebase.appid` points at it; import each distinct topic as an Application (`appname` = topic name), so KB links resolve against `applications` | `appname` | | `installedapps` | `POST /api/computers/{computerid}/apps` | body `{appid, appversionid}`; resolve `machineid` -> the imported computer, `appid`/`appversionid` -> imported app + version | (computerid, appid) | @@ -308,17 +316,6 @@ dispositions below are DECIDED, not open questions. future design should favor per-field fleet-wide updates over per-machine full-record imports. -### DECIDED: migrated into an upcoming model (do not build here) - -- **`supportteams` and `appowners`** - WILL be migrated, but into a new - `supportteams` / `supportteamcontacts` model that is being built separately - immediately after this task. Legacy `supportteams` (teams, with `teamurl`) - becomes the teams table; legacy `appowners` becomes the contacts, linked to a - team via `supportteams.appownerid`. Import ordering: create teams and their - contacts BEFORE applications, because `applications.supportteamid` references - a team. Do not build these entities as part of the import work - just point - the two legacy tables at that upcoming target. - ### DECIDED: skip (structure only or low value) - **`compliance`, `compliancescans`** - 0 rows in `prodscratch`. No data to diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 82f0e41..2781efc 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -470,20 +470,36 @@ export const applicationsApi = { }, updateInstalledApp(machineId, appId, data) { return api.put(`/applications/machines/${machineId}/${appId}`, data) + } +} + +// Support Teams API (teams + nested contacts) +export const supportteamsApi = { + list(params = {}) { + return api.get('/supportteams', { params }) }, - // Support teams - getSupportTeams() { - return api.get('/applications/supportteams') + get(id) { + return api.get(`/supportteams/${id}`) }, - createSupportTeam(data) { - return api.post('/applications/supportteams', data) + create(data) { + return api.post('/supportteams', data) }, - // App owners - getAppOwners() { - return api.get('/applications/appowners') + update(id, data) { + return api.put(`/supportteams/${id}`, data) }, - createAppOwner(data) { - return api.post('/applications/appowners', data) + remove(id) { + return api.delete(`/supportteams/${id}`) + }, + contacts: { + add(teamId, data) { + return api.post(`/supportteams/${teamId}/contacts`, data) + }, + update(teamId, contactId, data) { + return api.put(`/supportteams/${teamId}/contacts/${contactId}`, data) + }, + remove(teamId, contactId) { + return api.delete(`/supportteams/${teamId}/contacts/${contactId}`) + } } } diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js index d3a9927..d091bb5 100644 --- a/frontend/src/router/routes/core.js +++ b/frontend/src/router/routes/core.js @@ -89,6 +89,12 @@ export default [ component: () => import('../../views/settings/BusinessUnitsList.vue'), meta: { requiresAuth: true, requiresAdmin: true } }, + { + path: 'settings/supportteams', + name: 'supportteams', + component: () => import('../../views/settings/SupportTeamsList.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, { path: 'settings/dashboarddefaults', name: 'dashboarddefaults', diff --git a/frontend/src/views/applications/ApplicationDetail.vue b/frontend/src/views/applications/ApplicationDetail.vue index 70d0caa..9329edd 100644 --- a/frontend/src/views/applications/ApplicationDetail.vue +++ b/frontend/src/views/applications/ApplicationDetail.vue @@ -48,26 +48,26 @@
- +
-

Support Information

+

Support

Support Team - - {{ app.supportteam?.teamname || '-' }} + + {{ app.supportteamname || '-' }} - {{ app.supportteam?.teamname || '-' }} + {{ app.supportteamname || '-' }}
-
- 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