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 ### 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 - Plugin `get_permissions` hook (contract 0.10.0) so a plugin declares the RBAC
permissions its own routes enforce, instead of core accumulating every permissions its own routes enforce, instead of core accumulating every
plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The plugin's permissions in `Permission.PERMISSIONS` (plugin-is-the-product). The
@@ -184,6 +193,12 @@ ADR-007 and ADR-002.
### Fixed ### 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 - Measuring tools wired into the remaining cross-cutting surfaces an audit
found them missing from: generic asset serialization (typedata + pluginid, found them missing from: generic asset serialization (typedata + pluginid,
which also fixes relationship-card links to tools), map subtype 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-004: Deployment topology (per-site instances, not multi-tenant) - ACCEPTED
- ADR-005: Equipment vs measuringtools plugin scope - ACCEPTED - ADR-005: Equipment vs measuringtools plugin scope - ACCEPTED
- ADR-006: Plugin collector contract pattern - 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 ## 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. `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 ### Phases done
@@ -36,10 +41,11 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
### Active state ### Active state
- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build) - 781 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) - `__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 - 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 - Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
### Deferred ### 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. - 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. - 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. - (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 - (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.
- Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet) - (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 ## Quick start

View File

@@ -16,7 +16,7 @@ ShopDB tracks and manages:
**Backend:** **Backend:**
- Python 3.x with Flask - Python 3.x with Flask
- SQLAlchemy ORM - SQLAlchemy ORM
- MySQL 5.6+ database - MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
- JWT authentication - JWT authentication
- Plugin architecture for extensibility - 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. 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 - **computers** - Shopfloor PCs and workstations
- **employees** - Employee directory - **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 - **knowledgebase** - Documentation and troubleshooting guides
- **network** - Network devices - **network** - Network devices
- **notifications** - Shopfloor notifications and recognition feed - **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. | | `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. | | `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. | | `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. | | `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 ### branding

View File

@@ -8,7 +8,7 @@ the live code, not aspiration. The authoritative hook reference is
## Current version ## 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 `__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. 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.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.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.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 (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 `core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
0.2 baseline. 0.2 baseline.
@@ -50,6 +52,7 @@ land with a new or amended ADR.
| `get_navigation_items` | Sidebar menu entries | | `get_navigation_items` | Sidebar menu entries |
| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` | | `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` |
| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) | | `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)) | | 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) | | 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 | | Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin |

View File

@@ -1320,6 +1320,24 @@ td.actions {
font-size: 13px; 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 Tags */
.feature-tag { .feature-tag {
display: inline-block; display: inline-block;

View File

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

View File

@@ -120,6 +120,16 @@ export async function getPrinterHostnameTemplate() {
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') 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 // Apply per-site favicon + brand color at bootstrap. Empty settings keep the
// shipped defaults. Do not touch the style.css palette here. // shipped defaults. Do not touch the style.css palette here.
export async function applyBranding() { export async function applyBranding() {

View File

@@ -66,6 +66,10 @@
<span class="info-value"> <span class="info-value">
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line"> <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> {{ 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>
</span> </span>
</div> </div>
@@ -136,6 +140,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { applicationsApi } from '../../api' import { applicationsApi } from '../../api'
import { getContactEmailDomain } from '../../utils/siteSettings'
const route = useRoute() const route = useRoute()
@@ -143,9 +148,16 @@ const loading = ref(true)
const app = ref(null) const app = ref(null)
const versions = ref([]) const versions = ref([])
const installedOn = 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 () => { onMounted(async () => {
try { try {
contactEmailDomain.value = await getContactEmailDomain()
// Load application details // Load application details
const response = await applicationsApi.get(route.params.id) const response = await applicationsApi.get(route.params.id)
app.value = response.data.data app.value = response.data.data
@@ -155,7 +167,6 @@ onMounted(async () => {
const versionsRes = await applicationsApi.getVersions(route.params.id) const versionsRes = await applicationsApi.getVersions(route.params.id)
versions.value = versionsRes.data.data || [] versions.value = versionsRes.data.data || []
} catch (e) { } catch (e) {
console.log('No versions data')
} }
// Load installed on which PCs // Load installed on which PCs
@@ -163,7 +174,6 @@ onMounted(async () => {
const installedRes = await applicationsApi.getInstalledOn(route.params.id) const installedRes = await applicationsApi.getInstalledOn(route.params.id)
installedOn.value = installedRes.data.data || [] installedOn.value = installedRes.data.data || []
} catch (e) { } catch (e) {
console.log('No installed data')
} }
} catch (error) { } catch (error) {
console.error('Error loading application:', error) console.error('Error loading application:', error)

View File

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

View File

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

View File

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

View File

@@ -70,6 +70,7 @@ const LABELS = {
pc_access_domain: 'PC Access Domain', pc_access_domain: 'PC Access Domain',
employeeid_pattern: 'Employee ID Pattern', employeeid_pattern: 'Employee ID Pattern',
printer_hostname_template: 'Printer Hostname Template', printer_hostname_template: 'Printer Hostname Template',
contact_email_domain: 'Contact Email Domain',
dualpath_single_machine: 'Dualpath as Single Machine', dualpath_single_machine: 'Dualpath as Single Machine',
employee_directory_mode: 'Employee Directory Mode', employee_directory_mode: 'Employee Directory Mode',
usb_directory_mode: 'USB Directory Mode', usb_directory_mode: 'USB Directory Mode',
@@ -94,7 +95,7 @@ function isTrue(setting) {
const GROUPS = [ const GROUPS = [
{ title: 'Identity', keys: ['facility_name', 'site_base_url'] }, { title: 'Identity', keys: ['facility_name', 'site_base_url'] },
{ title: 'Behavior', keys: ['dualpath_single_machine'] }, { 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: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
{ title: 'System', keys: ['setup_complete'] } { 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.', 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.', 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', 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.' 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) { function fieldHelp(key) {

View File

@@ -13,7 +13,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width: 40px;"></th>
<th>Team Name</th> <th>Team Name</th>
<th>Link</th> <th>Link</th>
<th>Contacts</th> <th>Contacts</th>
@@ -21,64 +20,24 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="team in teams" :key="team.supportteamid"> <tr v-for="team in teams" :key="team.supportteamid">
<tr> <td>{{ team.teamname }}</td>
<td> <td>
<button class="btn btn-secondary btn-sm" @click="toggleExpand(team.supportteamid)"> <a v-if="team.teamurl" :href="team.teamurl" target="_blank">Link</a>
{{ expandedTeamId === team.supportteamid ? '-' : '+' }} <span v-else>-</span>
</button> </td>
</td> <td>
<td>{{ team.teamname }}</td> <button class="btn btn-secondary btn-sm" @click="openContactsModal(team)">
<td> Contacts ({{ team.contacts ? team.contacts.length : 0 }})
<a v-if="team.teamurl" :href="team.teamurl" target="_blank">Link</a> </button>
<span v-else>-</span> </td>
</td> <td class="actions">
<td>{{ team.contacts ? team.contacts.length : 0 }}</td> <button class="btn btn-secondary btn-sm" @click="openTeamModal(team)">Edit</button>
<td class="actions"> <button class="btn btn-danger btn-sm" @click="confirmDeleteTeam(team)">Delete</button>
<button class="btn btn-secondary btn-sm" @click="openTeamModal(team)">Edit</button> </td>
<button class="btn btn-danger btn-sm" @click="confirmDeleteTeam(team)">Delete</button> </tr>
</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"> <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 No support teams found
</td> </td>
</tr> </tr>
@@ -116,6 +75,56 @@
</div> </div>
</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 --> <!-- Contact Add/Edit Modal -->
<div v-if="showContactModal" class="modal-overlay" @click.self="showContactModal = false"> <div v-if="showContactModal" class="modal-overlay" @click.self="showContactModal = false">
<div class="modal"> <div class="modal">
@@ -165,16 +174,30 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { supportteamsApi } from '../../api' import { supportteamsApi } from '../../api'
import { useToast } from '../../composables/toast' import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError' import { apiError } from '../../utils/apiError'
import { getContactEmailDomain } from '../../utils/siteSettings'
const toast = useToast() const toast = useToast()
const teams = ref([]) const teams = ref([])
const loading = ref(true) const loading = ref(true)
const saving = ref(false) 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 showTeamModal = ref(false)
const editingTeam = ref(null) const editingTeam = ref(null)
@@ -190,7 +213,10 @@ const contactForm = ref({ name: '', sso: '', sortorder: 0 })
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
const toDelete = ref(null) const toDelete = ref(null)
onMounted(() => loadData()) onMounted(async () => {
contactEmailDomain.value = await getContactEmailDomain()
loadData()
})
async function loadData() { async function loadData() {
loading.value = true loading.value = true
@@ -204,8 +230,9 @@ async function loadData() {
} }
} }
function toggleExpand(teamId) { function openContactsModal(team) {
expandedTeamId.value = expandedTeamId.value === teamId ? null : teamId contactsTeamId.value = team.supportteamid
showContactsModal.value = true
} }
function openTeamModal(team = null) { function openTeamModal(team = null) {
@@ -283,7 +310,6 @@ async function saveContact() {
} }
showContactModal.value = false showContactModal.value = false
await loadData() await loadData()
expandedTeamId.value = teamId
} catch (err) { } catch (err) {
contactError.value = apiError(err, 'Failed to save') contactError.value = apiError(err, 'Failed to save')
} finally { } finally {
@@ -295,7 +321,6 @@ async function deleteContact(team, contact) {
try { try {
await supportteamsApi.contacts.remove(team.supportteamid, contact.contactid) await supportteamsApi.contacts.remove(team.supportteamid, contact.contactid)
await loadData() await loadData()
expandedTeamId.value = team.supportteamid
} catch (err) { } catch (err) {
toast.error(apiError(err, 'Failed to delete')) toast.error(apiError(err, 'Failed to delete'))
} }
@@ -303,15 +328,15 @@ async function deleteContact(team, contact) {
</script> </script>
<style scoped> <style scoped>
.contacts-panel { .contacts-modal {
padding: 0.5rem 0; max-width: 640px;
} }
.contacts-header { .contacts-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.5rem; margin-bottom: 0.75rem;
} }
.contacts-table { .contacts-table {

View File

@@ -9,6 +9,46 @@ from shopdb.utils.responses import success_response, error_response, ErrorCodes
auditlogs_bp = Blueprint('auditlogs', __name__) 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']) @auditlogs_bp.route('', methods=['GET'])
@jwt_required() @jwt_required()
def list_auditlogs(): def list_auditlogs():
@@ -75,8 +115,14 @@ def list_auditlogs():
# Paginate # Paginate
pagination = query.paginate(page=page, per_page=perpage, error_out=False) 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( return success_response(
[log.to_dict() for log in pagination.items], rows,
meta={ meta={
'page': page, 'page': page,
'perpage': perpage, 'perpage': perpage,

View File

@@ -434,6 +434,13 @@ def build_default_settings():
'category': 'site', 'category': 'site',
'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.' '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 # 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 'position': int, # order among panels
} }
Consumed by GET /api/assets/{assetid}/panels via the pluginui consumer, Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns
which returns the panels matching that asset's type. Disabled plugins the panels matching that asset's type. Disabled plugins
are skipped; a broken plugin is isolated in prod, re-raised in dev/test. 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. 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() by_key = _defaults_by_key()
assert by_key['employeeid_pattern']['value'] == r'^\d{9}$' assert by_key['employeeid_pattern']['value'] == r'^\d{9}$'
assert by_key['printer_hostname_template']['value'] == 'Printer-{ip}.printer.geaerospace.net' 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(): def test_defaults_changed_facility_and_map():