Add application support teams with contacts
All checks were successful
CI / backend (push) Successful in 1m7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

Replaces the legacy supportteams/appowners pair: supportteams
(teamname unique, teamurl ServiceNow link) + supportteamcontacts
(multiple named contacts with SSO per team, the people you reach out
to), applications.supportteamid intact. Migration 7d18 migrates each
legacy team owner into a contact, drops appowners, and has a validated
downgrade. New /api/supportteams CRUD (admin writes, import-mode
timestamps, teamname lookup), Support card on application detail,
contacts column on the list, and a settings management page.
IMPORT-API.md mapping updated to the concrete endpoints.

658 tests pass; live dev migration applied (24 teams / 24 contacts);
fresh-install and downgrade round-trips verified on scratch DBs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 20:29:12 -04:00
parent 46e50c07ff
commit 7dae281993
18 changed files with 1095 additions and 172 deletions

View File

@@ -12,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

View File

@@ -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

View File

@@ -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}`)
}
}
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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,

View File

@@ -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}`"

View 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>

View File

@@ -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)' },
],
},
{

View 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')

View File

@@ -114,6 +114,7 @@ CORE_BLUEPRINT_NAMES = (
'dashboard',
'dashboarddefaults',
'applications',
'supportteams',
'search',
'reports',
'collector',

View File

@@ -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',

View File

@@ -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.

View 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')

View File

@@ -12,7 +12,8 @@ from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType
from .communication import Communication, CommunicationType
from .user import User, Role, Permission
from .application import Application, AppVersion, AppOwner, SupportTeam
from .application import Application, AppVersion
from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting
from .auditlog import AuditLog
from .customfield import CustomField, CustomFieldValue
@@ -49,8 +50,9 @@ __all__ = [
# Applications
'Application',
'AppVersion',
'AppOwner',
# Support teams
'SupportTeam',
'SupportTeamContact',
# Knowledge Base
# Settings
'Setting',

View File

@@ -2,39 +2,8 @@
from shopdb.extensions import db
from .base import BaseModel
class AppOwner(BaseModel):
"""Application owner/contact."""
__tablename__ = 'appowners'
appownerid = db.Column(db.Integer, primary_key=True)
appowner = db.Column(db.String(100), nullable=False)
sso = db.Column(db.String(50))
email = db.Column(db.String(100))
# Relationships
supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic')
def __repr__(self):
return f"<AppOwner {self.appowner}>"
class SupportTeam(BaseModel):
"""Application support team."""
__tablename__ = 'supportteams'
supportteamid = db.Column(db.Integer, primary_key=True)
teamname = db.Column(db.String(100), nullable=False)
teamurl = db.Column(db.String(255))
appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid'))
# Relationships
owner = db.relationship('AppOwner', back_populates='supportteams')
applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic')
def __repr__(self):
return f"<SupportTeam {self.teamname}>"
# SupportTeam / SupportTeamContact live in supportteam.py; imported by the
# models package so the Application.supportteam relationship resolves.
class Application(BaseModel):
@@ -63,6 +32,22 @@ class Application(BaseModel):
supportteam = db.relationship('SupportTeam', back_populates='applications')
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
def to_dict(self):
"""Serialize, flattening the support team + its active contacts.
Emits supportteamname, teamurl, and the team's active contacts so the
frontend needs a single call to render the Support card.
"""
result = super().to_dict()
team = self.supportteam
result['supportteamname'] = team.teamname if team else None
result['teamurl'] = team.teamurl if team else None
result['contacts'] = [
{'name': c.name, 'sso': c.sso}
for c in team.active_contacts()
] if team else []
return result
def __repr__(self):
return f"<Application {self.appname}>"

View File

@@ -0,0 +1,61 @@
"""Application support teams and their contacts.
A support team is who you contact about an application; each team carries an
optional ServiceNow group deep link (teamurl) and a list of named contacts
(the people you actually reach out to, legacy called them "app owners").
Applications point at one team via applications.supportteamid.
"""
from shopdb.extensions import db
from .base import BaseModel
class SupportTeam(BaseModel):
"""A support team an application belongs to."""
__tablename__ = 'supportteams'
supportteamid = db.Column(db.Integer, primary_key=True)
teamname = db.Column(db.String(100), unique=True, nullable=False)
teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable
# Contacts cascade-delete with the team.
contacts = db.relationship(
'SupportTeamContact', back_populates='team',
cascade='all, delete-orphan', lazy='select')
applications = db.relationship(
'Application', back_populates='supportteam', lazy='dynamic')
def active_contacts(self):
"""Return active contacts in sortorder (then contactid) order."""
return sorted(
(c for c in self.contacts if c.isactive),
key=lambda c: (c.sortorder, c.contactid or 0))
def to_dict(self, with_contacts=True):
"""Serialize the team, nesting its active contacts by default."""
result = super().to_dict()
if with_contacts:
result['contacts'] = [c.to_dict() for c in self.active_contacts()]
return result
def __repr__(self):
return f"<SupportTeam {self.teamname}>"
class SupportTeamContact(BaseModel):
"""A person to contact for a support team."""
__tablename__ = 'supportteamcontacts'
contactid = db.Column(db.Integer, primary_key=True)
supportteamid = db.Column(
db.Integer,
db.ForeignKey('supportteams.supportteamid', ondelete='CASCADE'),
nullable=False)
name = db.Column(db.String(100), nullable=False)
sso = db.Column(db.String(50))
sortorder = db.Column(db.Integer, nullable=False, default=0)
team = db.relationship('SupportTeam', back_populates='contacts')
def __repr__(self):
return f"<SupportTeamContact {self.name}>"

View 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