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:
13
CHANGELOG.md
13
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -48,26 +48,26 @@
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support Info -->
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support Information</h3>
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.supportteam?.teamurl" :href="app.supportteam.teamurl" target="_blank">
|
||||
{{ app.supportteam?.teamname || '-' }}
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteam?.teamname || '-' }}</span>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">App Owner</span>
|
||||
<span class="info-value">{{ app.supportteam?.owner?.appowner || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.supportteam?.owner?.sso">
|
||||
<span class="info-label">SSO</span>
|
||||
<span class="info-value mono">{{ app.supportteam.owner.sso }}</span>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { applicationsApi } from '../../api'
|
||||
import { applicationsApi, supportteamsApi } from '../../api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -187,7 +187,7 @@ const supportTeams = ref([])
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load support teams
|
||||
const teamsRes = await applicationsApi.getSupportTeams()
|
||||
const teamsRes = await supportteamsApi.list()
|
||||
supportTeams.value = teamsRes.data.data || []
|
||||
|
||||
// Load application if editing
|
||||
@@ -198,7 +198,7 @@ onMounted(async () => {
|
||||
form.value = {
|
||||
appname: app.appname || '',
|
||||
appdescription: app.appdescription || '',
|
||||
supportteamid: app.supportteam?.supportteamid || '',
|
||||
supportteamid: app.supportteamid || '',
|
||||
isinstallable: app.isinstallable || false,
|
||||
islicenced: app.islicenced || false,
|
||||
isprinter: app.isprinter || false,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<th>Application Name</th>
|
||||
<th>Description</th>
|
||||
<th>Support Team</th>
|
||||
<th>App Owner</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -64,8 +64,8 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="description">{{ app.appdescription || '-' }}</td>
|
||||
<td>{{ app.supportteam?.teamname || '-' }}</td>
|
||||
<td>{{ app.supportteam?.owner?.appowner || '-' }}</td>
|
||||
<td>{{ app.supportteamname || '-' }}</td>
|
||||
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/applications/${app.appid}`"
|
||||
|
||||
320
frontend/src/views/settings/SupportTeamsList.vue
Normal file
320
frontend/src/views/settings/SupportTeamsList.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Support Teams</h2>
|
||||
<button class="btn btn-primary" @click="openTeamModal()">+ Add Support Team</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th>Team Name</th>
|
||||
<th>Link</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="team in teams" :key="team.supportteamid">
|
||||
<tr>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm" @click="toggleExpand(team.supportteamid)">
|
||||
{{ expandedTeamId === team.supportteamid ? '-' : '+' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ team.teamname }}</td>
|
||||
<td>
|
||||
<a v-if="team.teamurl" :href="team.teamurl" target="_blank">Link</a>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ team.contacts ? team.contacts.length : 0 }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openTeamModal(team)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDeleteTeam(team)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedTeamId === team.supportteamid">
|
||||
<td></td>
|
||||
<td colspan="4">
|
||||
<div class="contacts-panel">
|
||||
<div class="contacts-header">
|
||||
<strong>Contacts</strong>
|
||||
<button class="btn btn-primary btn-sm" @click="openContactModal(team)">+ Add Contact</button>
|
||||
</div>
|
||||
<table class="contacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>SSO</th>
|
||||
<th>Order</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="contact in team.contacts" :key="contact.contactid">
|
||||
<td>{{ contact.name }}</td>
|
||||
<td class="mono">{{ contact.sso || '-' }}</td>
|
||||
<td>{{ contact.sortorder }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openContactModal(team, contact)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteContact(team, contact)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!team.contacts || team.contacts.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No contacts yet
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="teams.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No support teams found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Team Add/Edit Modal -->
|
||||
<div v-if="showTeamModal" class="modal-overlay" @click.self="showTeamModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingTeam ? 'Edit Support Team' : 'Add Support Team' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveTeam">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="teamname">Team Name *</label>
|
||||
<input id="teamname" v-model="teamForm.teamname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teamurl">Team URL</label>
|
||||
<input id="teamurl" v-model="teamForm.teamurl" type="text" class="form-control" placeholder="ServiceNow group link" />
|
||||
</div>
|
||||
<div v-if="teamError" class="error-message">{{ teamError }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showTeamModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Add/Edit Modal -->
|
||||
<div v-if="showContactModal" class="modal-overlay" @click.self="showContactModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingContact ? 'Edit Contact' : 'Add Contact' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveContact">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="name">Name *</label>
|
||||
<input id="name" v-model="contactForm.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sso">SSO</label>
|
||||
<input id="sso" v-model="contactForm.sso" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sortorder">Sort Order</label>
|
||||
<input id="sortorder" v-model.number="contactForm.sortorder" type="number" class="form-control" />
|
||||
</div>
|
||||
<div v-if="contactError" class="error-message">{{ contactError }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showContactModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Team Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Delete Support Team</h3></div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>{{ toDelete?.teamname }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteTeam">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supportteamsApi } from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const teams = ref([])
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const expandedTeamId = ref(null)
|
||||
|
||||
const showTeamModal = ref(false)
|
||||
const editingTeam = ref(null)
|
||||
const teamError = ref('')
|
||||
const teamForm = ref({ teamname: '', teamurl: '' })
|
||||
|
||||
const showContactModal = ref(false)
|
||||
const editingContact = ref(null)
|
||||
const contactTeam = ref(null)
|
||||
const contactError = ref('')
|
||||
const contactForm = ref({ name: '', sso: '', sortorder: 0 })
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const toDelete = ref(null)
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await supportteamsApi.list({ active: false })
|
||||
teams.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading support teams:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(teamId) {
|
||||
expandedTeamId.value = expandedTeamId.value === teamId ? null : teamId
|
||||
}
|
||||
|
||||
function openTeamModal(team = null) {
|
||||
editingTeam.value = team
|
||||
teamForm.value = team
|
||||
? { teamname: team.teamname || '', teamurl: team.teamurl || '' }
|
||||
: { teamname: '', teamurl: '' }
|
||||
teamError.value = ''
|
||||
showTeamModal.value = true
|
||||
}
|
||||
|
||||
async function saveTeam() {
|
||||
teamError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
teamname: teamForm.value.teamname,
|
||||
teamurl: teamForm.value.teamurl || null
|
||||
}
|
||||
if (editingTeam.value) {
|
||||
await supportteamsApi.update(editingTeam.value.supportteamid, payload)
|
||||
} else {
|
||||
await supportteamsApi.create(payload)
|
||||
}
|
||||
showTeamModal.value = false
|
||||
loadData()
|
||||
} catch (err) {
|
||||
teamError.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteTeam(team) {
|
||||
toDelete.value = team
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteTeam() {
|
||||
try {
|
||||
await supportteamsApi.remove(toDelete.value.supportteamid)
|
||||
showDeleteModal.value = false
|
||||
toDelete.value = null
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
showDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openContactModal(team, contact = null) {
|
||||
contactTeam.value = team
|
||||
editingContact.value = contact
|
||||
contactForm.value = contact
|
||||
? { name: contact.name || '', sso: contact.sso || '', sortorder: contact.sortorder || 0 }
|
||||
: { name: '', sso: '', sortorder: 0 }
|
||||
contactError.value = ''
|
||||
showContactModal.value = true
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
contactError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const teamId = contactTeam.value.supportteamid
|
||||
const payload = {
|
||||
name: contactForm.value.name,
|
||||
sso: contactForm.value.sso || null,
|
||||
sortorder: contactForm.value.sortorder || 0
|
||||
}
|
||||
if (editingContact.value) {
|
||||
await supportteamsApi.contacts.update(teamId, editingContact.value.contactid, payload)
|
||||
} else {
|
||||
await supportteamsApi.contacts.add(teamId, payload)
|
||||
}
|
||||
showContactModal.value = false
|
||||
await loadData()
|
||||
expandedTeamId.value = teamId
|
||||
} catch (err) {
|
||||
contactError.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(team, contact) {
|
||||
try {
|
||||
await supportteamsApi.contacts.remove(team.supportteamid, contact.contactid)
|
||||
await loadData()
|
||||
expandedTeamId.value = team.supportteamid
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts-panel {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.contacts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
150
migrations/versions/7d18_supportteamcontacts.py
Normal file
150
migrations/versions/7d18_supportteamcontacts.py
Normal file
@@ -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')
|
||||
@@ -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}>"
|
||||
234
tests/test_core/test_supportteams.py
Normal file
234
tests/test_core/test_supportteams.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user