Final-pass polish: support contact UX, audit tooltip, doc refresh
All checks were successful
CI / backend (push) Successful in 1m21s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

Support teams: contact management moved from a row expander to a modal
(Contacts (N) button per team); application detail Support card and the
modal show Email (mailto) and Teams chat buttons for contacts with an
SSO, derived as sso@ + a new contact_email_domain site setting
(default geaerospace.com, blank hides the buttons).

Audit log: hovering a user SSO shows the full name, resolved
best-effort from the employee directory in either mode.

Docs/hygiene from a standards review: CLAUDE.md active-state,
CONTRACT-STABILITY.md and README brought to contract 0.10.0 / 11
plugins / migration head 7d22; get_asset_panels endpoint path fixed in
the hook docstring; leftover debug console.logs removed.

781 tests pass; contacts modal, action-button hrefs, and the audit
tooltip verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 11:22:01 -04:00
parent 1c5128a5c8
commit 7d309aabeb
18 changed files with 234 additions and 90 deletions

View File

@@ -12,6 +12,15 @@ ADR-007 and ADR-002.
### Added
- Support-team contact UX: the settings Support Teams page now manages each
team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus
Add/Edit/Delete) instead of an inline row expander, and the application
detail Support card renders Email (`mailto:`) and Microsoft Teams chat
(`teams.microsoft.com/l/chat`) action buttons for every contact that has an
SSO. Both link targets derive as `sso@<domain>` from a new `site` setting
`contact_email_domain` (default `geaerospace.com`; blank hides the buttons),
surfaced in Site & Facility settings under Naming & Patterns and read on the
frontend via `getContactEmailDomain()`.
- Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC
permissions its own routes enforce, instead of core accumulating every
plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The
@@ -184,6 +193,12 @@ ADR-007 and ADR-002.
### Fixed
- Audit log: hovering a user's SSO now shows their full name (best-effort,
resolved from the employee directory in either mode).
- Refreshed the internal status docs to match the code (CLAUDE.md active
state, CONTRACT-STABILITY.md and README plugin list at contract 0.10.0),
corrected the get_asset_panels endpoint path in the hook docstring, and
removed leftover debug console.log lines.
- Measuring tools wired into the remaining cross-cutting surfaces an audit
found them missing from: generic asset serialization (typedata + pluginid,
which also fixes relationship-card links to tools), map subtype

View File

@@ -16,14 +16,19 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c
- ADR-004: Deployment topology (per-site instances, not multi-tenant) - ACCEPTED
- ADR-005: Equipment vs measuringtools plugin scope - ACCEPTED
- ADR-006: Plugin collector contract pattern - ACCEPTED
- ADR-007: Product versioning and releases - ACCEPTED
- ADR-008: Plugin migration ownership (per-plugin chains) - ACCEPTED
- ADR-009: Frontend plugin route gating - ACCEPTED
- ADR-010: Frontend plugin hook contract - ACCEPTED
- ADR-011: Machines rename + modeltypes retyping - ACCEPTED
## Coding convention
`CONTRIBUTING.md` defines naming rules (DB tables, columns, Python, JS, Vue, API). Pre-commit hook at `scripts/check-naming-and-style.sh` enforces them. Read `CONTRIBUTING.md` before naming any new identifier.
## Current state (as of 2026-07-10)
## Current state (as of 2026-07-12)
Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progress.
Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely complete; the last big milestone is the legacy-data import + a production pilot.
### Phases done
@@ -36,10 +41,11 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
### Active state
- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
- `__contract_version__` at 0.6.0 (product `__version__` 0.5.0 - distinct series, ADR-007)
- 781 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
- `__contract_version__` at 0.10.0 (product `__version__` 0.6.0, tags v0.5.0/v0.6.0 - distinct series, ADR-007; a large [Unreleased] pile is due a release cut)
- 11 bundled plugins all satisfy contract: computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent.
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d22_apitokens_scopes` (29 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty.
- API is migration-complete: an admin PAT + docs/IMPORT-API.md let a script/LLM import the whole legacy DB (X-Import-Mode preserves timestamps).
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
### Deferred
@@ -47,8 +53,9 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
- Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run.
- Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration.
- (DONE 2026-07-11) `measuringtools` plugin (ADR-005) is built and bundled; docs/PLUGIN-GUIDE.md narrates its construction as the plugin tutorial.
- Frontend hook contract for asset-detail, map markers, search results
- Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet)
- (DONE) Frontend plugin hook contract (ADR-010): get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation shipped; generic renderers for panels/overlays land incrementally.
- (DONE) Per-plugin Alembic chains (ADR-008): every bundled plugin carries its own chain; no plugin uses db.create_all().
- Legacy ASP data import against the renamed schema (unblocked; run via docs/IMPORT-API.md) + a production pilot deployment.
## Quick start

View File

@@ -16,7 +16,7 @@ ShopDB tracks and manages:
**Backend:**
- Python 3.x with Flask
- SQLAlchemy ORM
- MySQL 5.6+ database
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
- JWT authentication
- Plugin architecture for extensibility
@@ -204,11 +204,12 @@ Query parameters for list endpoints:
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
The image bundles ten plugins; only the ones a site installs are loaded:
The image bundles eleven plugins; only the ones a site installs are loaded:
- **computers** - Shopfloor PCs and workstations
- **employees** - Employee directory
- **machine** - CNC, CMM, and other shop-floor machines
- **machines** - CNC, CMM, and other shop-floor machines
- **measuringtools** - Gage-lab instruments with calibration tracking
- **knowledgebase** - Documentation and troubleshooting guides
- **network** - Network devices
- **notifications** - Shopfloor notifications and recognition feed

View File

@@ -143,6 +143,7 @@ read back through the API.
| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. |
| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. |
| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. |
| `contact_email_domain` | `geaerospace.com` | Email domain appended to a support contact's SSO to build email (`sso@domain`) and Teams-chat links. Blank hides the contact action buttons. |
| `dualpath_single_machine` | `true` | Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in the machines list, dashboard/report counts, and the floor map (the secondary bay is hidden). The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. `false` lists and counts both bays separately. |
### branding

View File

@@ -8,7 +8,7 @@ the live code, not aspiration. The authoritative hook reference is
## Current version
The plugin contract is at **0.7.0**, declared in `shopdb/__init__.py` as
The plugin contract is at **0.10.0**, declared in `shopdb/__init__.py` as
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
bump is allowed to break the contract, and this project has used that latitude.
@@ -26,8 +26,10 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.4.0 | Removed the never-implemented `get_searchable_fields` hook (search is a core concern over the asset model) and wired `get_dashboard_widgets` to a real consumer (`/api/dashboard/widgets`) | pre-1.0 contract reduction |
| 0.6.0 | Added the `get_reports` hook, consumed by `GET /api/reports` to merge plugin report cards into the Reports hub | additive optional hook (minor) |
| 0.7.0 | Added the four ADR-010 frontend-contribution hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`), consumed by the `GET /api/pluginui/*` endpoints | additive optional hooks (minor) |
| 0.9.0 | Exposed the dualpath pair-resolution helpers on `shopdb.api` for the machines plugin | additive surface (minor) |
| 0.10.0 | Added the `get_permissions` hook so plugins declare their own RBAC permissions; the catalog is resolved dynamically from core + enabled plugins | additive optional hook (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, and 0.7.0. Earlier points
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, and 0.10.0. Earlier points
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
0.2 baseline.
@@ -50,6 +52,7 @@ land with a new or amended ADR.
| `get_navigation_items` | Sidebar menu entries |
| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` |
| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) |
| `get_permissions` | Plugin RBAC permissions, merged into the catalog for roles and token scopes (added 0.10.0) |
| Frontend-contribution hooks | `get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`, consumed by `/api/pluginui/*` (added 0.7.0, [ADR-010](adr/ADR-010-frontend-plugin-hooks.md)) |
| Collector pair | `get_collector_schema` + `apply_collector_payload` per [ADR-006](adr/ADR-006-collector-contract.md) |
| Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin |

View File

@@ -1320,6 +1320,24 @@ td.actions {
font-size: 13px;
}
/* Small email/Teams action buttons for support contacts */
.contact-action {
display: inline-block;
margin-left: 0.35rem;
padding: 0.05rem 0.4rem;
font-size: 12px;
line-height: 1.5;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--link);
background: var(--bg);
text-decoration: none;
}
.contact-action:hover {
border-color: var(--primary);
color: var(--primary);
}
/* Feature Tags */
.feature-tag {
display: inline-block;

View File

@@ -344,7 +344,6 @@ async function resolveAssetId() {
resolvedAssetId.value = response.data.data?.assetid
lookupFailed.value = !resolvedAssetId.value
} catch (error) {
console.log('Asset lookup failed for:', props.machineNumber)
resolvedAssetId.value = null
lookupFailed.value = true
loading.value = false

View File

@@ -120,6 +120,16 @@ export async function getPrinterHostnameTemplate() {
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net')
}
// Email domain appended to a contact SSO to build email/Teams links
// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value
// means "disable contact action buttons", so read the raw setting rather than
// getSetting (which swaps '' for the fallback).
export async function getContactEmailDomain() {
const settings = await loadSettings()
const value = settings['contact_email_domain']
return (value === undefined || value === null) ? 'geaerospace.com' : value
}
// Apply per-site favicon + brand color at bootstrap. Empty settings keep the
// shipped defaults. Do not touch the style.css palette here.
export async function applyBranding() {

View File

@@ -66,6 +66,10 @@
<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>
<template v-if="contact.sso && contactEmailDomain">
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
</template>
</span>
</span>
</div>
@@ -136,6 +140,7 @@
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { applicationsApi } from '../../api'
import { getContactEmailDomain } from '../../utils/siteSettings'
const route = useRoute()
@@ -143,9 +148,16 @@ const loading = ref(true)
const app = ref(null)
const versions = ref([])
const installedOn = ref([])
const contactEmailDomain = ref('')
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
function contactEmail(contact) {
return `${contact.sso}@${contactEmailDomain.value}`
}
onMounted(async () => {
try {
contactEmailDomain.value = await getContactEmailDomain()
// Load application details
const response = await applicationsApi.get(route.params.id)
app.value = response.data.data
@@ -155,7 +167,6 @@ onMounted(async () => {
const versionsRes = await applicationsApi.getVersions(route.params.id)
versions.value = versionsRes.data.data || []
} catch (e) {
console.log('No versions data')
}
// Load installed on which PCs
@@ -163,7 +174,6 @@ onMounted(async () => {
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
installedOn.value = installedRes.data.data || []
} catch (e) {
console.log('No installed data')
}
} catch (error) {
console.error('Error loading application:', error)

View File

@@ -523,7 +523,6 @@ onMounted(async () => {
}
}
} catch (e) {
console.log('Could not load relationships')
}
}
}

View File

@@ -268,7 +268,6 @@ onMounted(async () => {
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
installedApps.value = appsResponse.data.data || []
} catch (appError) {
console.log('No installed apps data:', appError.message)
}
// Warranties load via useWarrantyBadge (watches computer.assetid).
} catch (error) {

View File

@@ -61,7 +61,7 @@
</router-link>
<span v-else>{{ log.entityname || `#${log.entityid}` }}</span>
</td>
<td>{{ log.username || '-' }}</td>
<td :title="log.userfullname || ''">{{ log.username || '-' }}</td>
<td class="ip">{{ log.ipaddress || '-' }}</td>
<td>
<button

View File

@@ -70,6 +70,7 @@ const LABELS = {
pc_access_domain: 'PC Access Domain',
employeeid_pattern: 'Employee ID Pattern',
printer_hostname_template: 'Printer Hostname Template',
contact_email_domain: 'Contact Email Domain',
dualpath_single_machine: 'Dualpath as Single Machine',
employee_directory_mode: 'Employee Directory Mode',
usb_directory_mode: 'USB Directory Mode',
@@ -94,7 +95,7 @@ function isTrue(setting) {
const GROUPS = [
{ title: 'Identity', keys: ['facility_name', 'site_base_url'] },
{ title: 'Behavior', keys: ['dualpath_single_machine'] },
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'employeeid_pattern'] },
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
{ title: 'System', keys: ['setup_complete'] }
]
@@ -106,6 +107,7 @@ const HELP = {
setup_complete: 'Set automatically when the first-run setup wizard finishes. Turning it off sends admins back to the /setup wizard on next login.',
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net',
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.'
}
function fieldHelp(key) {

View File

@@ -13,7 +13,6 @@
<table>
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Team Name</th>
<th>Link</th>
<th>Contacts</th>
@@ -21,64 +20,24 @@
</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-for="team in teams" :key="team.supportteamid">
<td>{{ team.teamname }}</td>
<td>
<a v-if="team.teamurl" :href="team.teamurl" target="_blank">Link</a>
<span v-else>-</span>
</td>
<td>
<button class="btn btn-secondary btn-sm" @click="openContactsModal(team)">
Contacts ({{ team.contacts ? team.contacts.length : 0 }})
</button>
</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="teams.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No support teams found
</td>
</tr>
@@ -116,6 +75,56 @@
</div>
</div>
<!-- Contacts List Modal (per team) -->
<div v-if="showContactsModal && contactsTeam" class="modal-overlay" @click.self="showContactsModal = false">
<div class="modal contacts-modal">
<div class="modal-header">
<h3>{{ contactsTeam.teamname }} - Contacts</h3>
</div>
<div class="modal-body">
<div class="contacts-header">
<strong>Contacts ({{ contactsTeam.contacts ? contactsTeam.contacts.length : 0 }})</strong>
<button class="btn btn-primary btn-sm" @click="openContactModal(contactsTeam)">+ Add Contact</button>
</div>
<table class="contacts-table">
<thead>
<tr>
<th>Name</th>
<th>SSO</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="contact in contactsTeam.contacts" :key="contact.contactid">
<td>
{{ contact.name }}
<template v-if="contact.sso && contactEmailDomain">
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
</template>
</td>
<td class="mono">{{ contact.sso || '-' }}</td>
<td>{{ contact.isactive === false ? 'No' : 'Yes' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openContactModal(contactsTeam, contact)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteContact(contactsTeam, contact)">Delete</button>
</td>
</tr>
<tr v-if="!contactsTeam.contacts || contactsTeam.contacts.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No contacts yet
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showContactsModal = false">Close</button>
</div>
</div>
</div>
<!-- Contact Add/Edit Modal -->
<div v-if="showContactModal" class="modal-overlay" @click.self="showContactModal = false">
<div class="modal">
@@ -165,16 +174,30 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { supportteamsApi } from '../../api'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { getContactEmailDomain } from '../../utils/siteSettings'
const toast = useToast()
const teams = ref([])
const loading = ref(true)
const saving = ref(false)
const expandedTeamId = ref(null)
const contactEmailDomain = ref('')
// Contacts list modal. Track the team id (not the object) so the computed
// re-resolves against the freshly loaded teams array after any CRUD reload.
const showContactsModal = ref(false)
const contactsTeamId = ref(null)
const contactsTeam = computed(() =>
teams.value.find(t => t.supportteamid === contactsTeamId.value) || null
)
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
function contactEmail(contact) {
return `${contact.sso}@${contactEmailDomain.value}`
}
const showTeamModal = ref(false)
const editingTeam = ref(null)
@@ -190,7 +213,10 @@ const contactForm = ref({ name: '', sso: '', sortorder: 0 })
const showDeleteModal = ref(false)
const toDelete = ref(null)
onMounted(() => loadData())
onMounted(async () => {
contactEmailDomain.value = await getContactEmailDomain()
loadData()
})
async function loadData() {
loading.value = true
@@ -204,8 +230,9 @@ async function loadData() {
}
}
function toggleExpand(teamId) {
expandedTeamId.value = expandedTeamId.value === teamId ? null : teamId
function openContactsModal(team) {
contactsTeamId.value = team.supportteamid
showContactsModal.value = true
}
function openTeamModal(team = null) {
@@ -283,7 +310,6 @@ async function saveContact() {
}
showContactModal.value = false
await loadData()
expandedTeamId.value = teamId
} catch (err) {
contactError.value = apiError(err, 'Failed to save')
} finally {
@@ -295,7 +321,6 @@ 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'))
}
@@ -303,15 +328,15 @@ async function deleteContact(team, contact) {
</script>
<style scoped>
.contacts-panel {
padding: 0.5rem 0;
.contacts-modal {
max-width: 640px;
}
.contacts-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
.contacts-table {

View File

@@ -9,6 +9,46 @@ from shopdb.utils.responses import success_response, error_response, ErrorCodes
auditlogs_bp = Blueprint('auditlogs', __name__)
def _resolve_full_names(ssos):
"""Best-effort SSO -> full name map for audit rows. Mode-aware, guarded,
degrades to {} so the list still renders if the directory is unreachable."""
wanted = {s for s in ssos if s and str(s).isdigit()}
if not wanted:
return {}
names = {}
# Self-hosted directory first (no external dependency).
try:
from plugins.employees.models import DirectoryEmployee
rows = DirectoryEmployee.query.filter(
DirectoryEmployee.sso.in_(wanted)).all()
for emp in rows:
full = f'{(emp.firstname or "").strip()} {(emp.lastname or "").strip()}'.strip()
if full:
names[str(emp.sso)] = full
except Exception:
pass
missing = wanted - set(names)
if not missing:
return names
# External HR directory for anything still unresolved.
try:
from shopdb.utils.employee_db import employee_connection
conn = employee_connection()
placeholders = ','.join(['%s'] * len(missing))
with conn.cursor() as cur:
cur.execute(
'SELECT SSO, First_Name, Last_Name FROM employees '
f'WHERE SSO IN ({placeholders})', tuple(missing))
for emp in cur.fetchall():
full = f'{(emp["First_Name"] or "").strip()} {(emp["Last_Name"] or "").strip()}'.strip()
if full:
names[str(emp['SSO'])] = full
conn.close()
except Exception:
pass
return names
@auditlogs_bp.route('', methods=['GET'])
@jwt_required()
def list_auditlogs():
@@ -75,8 +115,14 @@ def list_auditlogs():
# Paginate
pagination = query.paginate(page=page, per_page=perpage, error_out=False)
rows = [log.to_dict() for log in pagination.items]
# Attach full names (hover tooltip on the SSO); best-effort.
fullnames = _resolve_full_names({r.get('username') for r in rows})
for r in rows:
r['userfullname'] = fullnames.get(str(r.get('username')))
return success_response(
[log.to_dict() for log in pagination.items],
rows,
meta={
'page': page,
'perpage': perpage,

View File

@@ -434,6 +434,13 @@ def build_default_settings():
'category': 'site',
'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.'
},
{
'key': 'contact_email_domain',
'value': 'geaerospace.com',
'valuetype': 'string',
'category': 'site',
'description': 'Email domain appended to a contact SSO to build email/Teams links (sso@domain). Blank disables contact action buttons.'
},
]
# Site branding. Each instance (ADR-004) can replace the shipped GE defaults

View File

@@ -301,8 +301,8 @@ class BasePlugin(ABC):
'position': int, # order among panels
}
Consumed by GET /api/assets/{assetid}/panels via the pluginui consumer,
which returns the panels matching that asset's type. Disabled plugins
Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns
the panels matching that asset's type. Disabled plugins
are skipped; a broken plugin is isolated in prod, re-raised in dev/test.
A panel needing bespoke UI is out of scope for the data-only hook.
"""

View File

@@ -154,6 +154,8 @@ def test_defaults_contain_new_site_keys():
by_key = _defaults_by_key()
assert by_key['employeeid_pattern']['value'] == r'^\d{9}$'
assert by_key['printer_hostname_template']['value'] == 'Printer-{ip}.printer.geaerospace.net'
assert by_key['contact_email_domain']['value'] == 'geaerospace.com'
assert by_key['contact_email_domain']['category'] == 'site'
def test_defaults_changed_facility_and_map():