16 Commits

Author SHA1 Message Date
cproudlock
3a3dff285e printedparts stage 17: gage-lab asset tag + print-files redesign
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The gage lab assigns real WJRP asset numbers, so identity splits: the
internal itemcode stays auto-minted and a new optional unique
gagelabtag (migration 0003) carries the lab's number - settable on
create/edit, searchable, and resolved by the kiosk for scans and bare
keypad digits against the numeric tail of either identifier
(unique-match only). The print-files table becomes stacked revision
cards - filename with rev/current badges, one meta line, delete pinned
right - ending the horizontal scroll in that column.
2026-07-17 14:01:09 -04:00
cproudlock
6160a5142a Dark mode: dropdown arrow no longer tiles across selects
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The dark .form-control override used the background shorthand, which
resets a select's background-repeat and position; the dark select rule
then re-added the arrow image without them, tiling it from the top
left. Use background-color in the overrides and restate
no-repeat/position on the select rule.
2026-07-17 13:37:24 -04:00
cproudlock
d75e80ce79 Plugin lab rewritten as the literal type-along walkthrough
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The milestone workbook becomes a from-scratch guide with the actual
code inline for every core stage: models, the real migration baseline,
read routes and the list page, mutations and minting, the badge
resolver (final mode-aware form), the single-commit ledger invariant,
RBAC gating, both kiosk endpoints with the wedge-input and focus-guard
mechanics, the 1x0.5in label CSS, and the reconcile query. Field
extensions stay summarized against their tags. New section: how to
contribute a plugin through GitHub (branch, stage commits, the three
CI gates, PR expectations, review checklist, and how publication
folds PRs into release commits).
2026-07-17 13:35:02 -04:00
cproudlock
ee80d684d4 Shopfloor feed resolves employee names live when none is stored
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Photos already resolved through the directory at read time, but names
only came from the stored employeename column - empty after a
shopdb-only import, so recertification/recognition cards showed bare
SSOs. New resolve_employee_display_name in the employees plugin
(mode-aware: self-hosted table or external HR) backs a fallback in
both the single-card and split-per-employee paths; stored names still
win when present.
2026-07-17 13:29:34 -04:00
cproudlock
bc9159742c printedparts lab: post-stage polish addendum and closing lesson
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
2026-07-17 13:24:11 -04:00
cproudlock
e9235de8ec Floor-map previews honor the mount path
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The uploaded-blueprint thumbnails on the map settings page and the
setup wizard used the raw setting value (/api/settings/map-blueprint/
...), which resolves at the server root and 404s under a subpath
mount - while the map itself resolves through blueprintUrlFor and
worked. Wrap the previews in withBase.
2026-07-17 11:43:06 -04:00
cproudlock
4f3ea2848a GE monogram avatar fallback + per-page document titles
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Users without a profile photo (and broken photo URLs) show the GE
monogram instead of nothing/initials - sidebar identity, employee
detail hero, and the directory list thumbs; the shopfloor cards
already did this. Document titles become
'<Facility> ShopDB - <Page>' via a router afterEach (facility from
public settings, page label from meta.title or a prettified route
name with spellings for PCs/USB/GE-Enforce/3D Printed Parts/...), so
copied links and browser tabs identify the page.
2026-07-17 11:31:38 -04:00
cproudlock
5625608bd0 Employees: external photo base URL is a setting
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
External HR Picture values are relative paths; the resolver hardcoded
/static/employees/ (which the SPA then mounts under the subpath, e.g.
/ops/static/...), but sites like WJ serve those photos from the
classic EmployeeDBAPP on another URL entirely. New setting
employee_photo_base_url (blank keeps the old behavior; a full URL like
https://host/EmployeeDBAPP/images/ passes through withBase untouched),
declared in the plugin config schema.
2026-07-17 11:16:04 -04:00
cproudlock
0cc205d25e Users: deleting a user clears their API tokens and detaches audit rows
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Deleting any user who owned an API token or appeared in the audit log
hit the users FK and 500ed - the import's 'importer' account being the
guaranteed case (its PAT plus every audit row the import wrote).
Tokens are revoked outright; audit history is kept but detached
(userid NULL), so the trail survives the account.
2026-07-17 10:54:47 -04:00
cproudlock
bb5308bae0 printedparts: badge resolution honors the employee directory mode
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The resolver only read the self-hosted directory table, which is empty
at sites running the external HR directory - every kiosk badge fell to
the deny policy. It now branches on employee_directory_mode like the
usb plugin: selfhosted looks up DirectoryEmployee by SSO; external
queries the HR directory via employee_connection, resolving PayNo
badges by their real PayNo column and recovering the employee's SSO.
2026-07-17 10:39:11 -04:00
cproudlock
d297c5b75d IIS runbook: app pool needs Modify on instance/
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The plugin registry (instance/plugins.json), uploaded logos, floor
plans, item photos, and print files all write under instance/; with
the app pool at read-only, toggling a plugin in Settings surfaces as
an internal error and every upload fails. Grant Modify in step 7.3
and add the troubleshooting row.
2026-07-17 10:34:52 -04:00
cproudlock
deb6dd2162 Ignore the publication clone's _transfer bundle folder
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
2026-07-17 10:01:34 -04:00
cproudlock
02ed88c7c5 Merge printedparts: 3D-printed parts storefront, kiosk, labels, alerts
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The plugin-lab exemplar built end to end: catalog with photos and
print-file revisions, badge-attributed stock ledger, touch kiosk with
an open decrement-only take endpoint (decision record), 1x0.5in bin
labels, low-stock alerts to users/roles/emails, reports with a
reconcile check, per-plugin migrations 0001+0002, contract 0.13.0
(mailer + User/Role on the plugin surface).
2026-07-17 09:20:43 -04:00
cproudlock
d1357defc4 printedparts: catalog access is printedparts.view-gated
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
Browsing the catalog (item list, detail, file listings) now requires
authentication plus the view permission, and the /printedparts pages
and the label print page require login. Still deliberately open: the
kiosk endpoints per the decision record, the image serve and file
download (img tags and anchor downloads cannot carry a JWT), and the
reports (product-wide jwt-optional convention). Grant
printedparts.view to the roles that should see the catalog.
2026-07-17 09:19:30 -04:00
cproudlock
96e48e0f50 printedparts kiosk: keypad and entry-panel visual polish
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
The keypad becomes a proper terminal pad: fixed 3-column grid of
rounded square buttons with tabular numerals, press feedback, and
muted Clear/backspace actions. Each manual step (item number, SSO,
quantity) shares one card panel - boxed entry display with placeholder
styling, keypad, and a full-width action button.
2026-07-17 09:13:27 -04:00
cproudlock
4dfdb167d5 printedparts stage 16: kiosk touch fixes from first hands-on use
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The tap-anywhere wedge refocus stole focus from the manual-entry field
the moment it was tapped - the handler now only reclaims focus from
dead space, never from a real control. Manual entry works without a
physical keyboard: badge entry uses the TouchKeypad (an SSO is
digits), and item lookup accepts bare digits resolved by row id - the
digits in a minted code are the id, which also keeps labels printed
under an older prefix scannable after the prefix changes.
2026-07-17 09:04:52 -04:00
27 changed files with 2059 additions and 813 deletions

3
.gitignore vendored
View File

@@ -76,3 +76,6 @@ secrets.yml
*_secrets
credentials.json
scripts/site_imports/wjf/idmap.json
# work-PC publication clone: bundle drop folder for the transfer pipeline
_transfer/

View File

@@ -151,6 +151,8 @@ Two supported deployment methods:
```powershell
icacls APP_ROOT /grant "IIS AppPool\shopdbflask:(OI)(CI)RX" /T
icacls APP_ROOT\logs /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
mkdir APP_ROOT\instance 2>NUL
icacls APP_ROOT\instance /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
```
4. **Unlock the handler sections** (locked server-wide by default; without this
IIS returns **HTTP 500.19**):
@@ -236,6 +238,7 @@ each gets its own site, app pool, port, and venv.
| IIS **500.52** after enabling the rewrite block | `allowedServerVariables` locked at server level - `appcmd unlock config -section:system.webServer/rewrite/allowedServerVariables`. |
| Audit log shows only **127.0.0.1** with the rewrite block active | waitress strips untrusted proxy headers - `--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for` missing from the waitress `arguments`. |
| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. |
| "internal error" toggling plugins, or uploads fail | app pool cannot WRITE `APP_ROOT\instance` (plugin registry, logos, photos, files live there) - step 7.3 grants it Modify. |
| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). |
| Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
| Method B: blank page / assets 404 under `/ops` | frontend `dist` built without `VITE_BASE_PATH=/ops/` (step 7b.1). |

File diff suppressed because it is too large Load Diff

View File

@@ -628,18 +628,22 @@ input[type="radio"] {
theme store always stamps it at startup) - a bare prefers-color-scheme
query here leaks dark widget styles into light mode on dark-OS machines. */
[data-theme="dark"] .form-control {
background: var(--bg);
/* background-COLOR, not the shorthand: the shorthand resets a select's
background-repeat/position and the dropdown arrow tiles across the box. */
background-color: var(--bg);
border-color: var(--border);
}
[data-theme="dark"] .form-control:focus {
background: var(--bg);
background-color: var(--bg);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
[data-theme="dark"] select.form-control {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-color: var(--bg);
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
[data-theme="dark"] select.form-control option {

View File

@@ -4,11 +4,11 @@
class="keypad-button" @click="$emit('digit', digit)">
{{ digit }}
</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('clear')">C</button>
<button type="button" class="keypad-button keypad-action"
@click="$emit('clear')">Clear</button>
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('backspace')">&lt;</button>
<button type="button" class="keypad-button keypad-action"
aria-label="Backspace" @click="$emit('backspace')">&#9003;</button>
</div>
</template>
@@ -20,19 +20,35 @@ defineEmits(['digit', 'clear', 'backspace'])
<style scoped>
.touch-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
max-width: 20rem;
grid-template-columns: repeat(3, 5.2rem);
gap: 0.65rem;
justify-content: center;
}
.keypad-button {
font-size: 1.8rem;
padding: 1rem 0;
border-radius: 0.5rem;
height: 4.4rem;
font-size: 1.9rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
border-radius: 0.75rem;
border: 1px solid var(--border);
background: var(--bg-card);
background: var(--bg-card-solid, var(--bg-card));
color: var(--text);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
transition: transform 0.05s ease, background 0.1s ease;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.keypad-button:active { background: var(--primary); color: #fff; }
.keypad-muted { color: var(--text-light); }
.keypad-button:hover { border-color: var(--primary); }
.keypad-button:active {
background: var(--primary);
color: #fff;
transform: scale(0.96);
}
.keypad-action {
font-size: 1.1rem;
font-weight: 500;
color: var(--text-light);
}
.keypad-action:active { color: #fff; }
</style>

View File

@@ -5,6 +5,7 @@ import SettingsLayout from '../views/settings/SettingsLayout.vue'
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings'
// Auto-discover all route modules from routes/ directory
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
@@ -118,10 +119,12 @@ const routes = [
meta: { plugin: 'usb' }
},
{
// Unlike the other print pages this one requires login: it lists the
// whole catalog, which is printedparts.view-gated at the API.
path: '/print/printedparts-labels',
name: 'print-printedparts-labels',
component: () => import('../views/print/PrintedPartsLabels.vue'),
meta: { plugin: 'printedparts' }
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: '/',
@@ -135,6 +138,43 @@ const router = createRouter({
routes
})
// --- Document titles: "<Facility> ShopDB - <Page>" ---------------------------
// Facility name comes from public settings (cached after first fetch); the
// page label comes from meta.title when a route sets one, else a prettified
// route name with spellings for the odd ones.
const TITLE_SPELLINGS = {
'pcs': 'PCs',
'usb': 'USB Devices',
'geenforce': 'GE-Enforce',
'knowledgebase': 'Knowledge Base',
'printedparts': '3D Printed Parts',
'parts-kiosk': 'Parts Kiosk',
'tv': 'TV Slideshow',
'shopfloor': 'Shopfloor Dashboard',
'measuringtools': 'Measuring Tools',
'networkdevices': 'Network',
}
function pageTitleFor(route) {
if (route.meta?.title) return route.meta.title
const name = String(route.name || '')
if (!name) return ''
const base = name.replace(/-(new|edit|detail)$/, '')
if (TITLE_SPELLINGS[base]) return TITLE_SPELLINGS[base]
return base.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
router.afterEach(async (to) => {
let siteTitle = 'ShopDB'
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB`
} catch (titleError) { /* settings unavailable - plain ShopDB */ }
const page = pageTitleFor(to)
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
})
// Navigation guard
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()

View File

@@ -12,7 +12,7 @@ export default [
path: 'printedparts',
name: 'printedparts',
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
meta: { plugin: 'printedparts' }
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/new',
@@ -24,7 +24,7 @@ export default [
path: 'printedparts/:id',
name: 'printedparts-detail',
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
meta: { plugin: 'printedparts' }
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/:id/edit',

View File

@@ -42,7 +42,8 @@
<div class="user-menu">
<template v-if="authStore.isAuthenticated">
<div class="user-identity">
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
<img :src="authStore.avatarUrl || fallbackAvatar" class="user-avatar"
:class="{ 'ge-avatar-fallback': !authStore.avatarUrl }"
:alt="authStore.displayName" @error="onAvatarError" />
<div class="user-ident">
<div class="username">{{ authStore.displayName }}</div>
@@ -245,9 +246,12 @@ async function handleLogout() {
router.push('/login')
}
// Hide a broken avatar (photo filename set but file missing).
const fallbackAvatar = withBase('/ge-monogram.svg')
// A broken avatar (photo set but file missing) degrades to the GE monogram.
function onAvatarError(event) {
event.target.style.display = 'none'
event.target.src = fallbackAvatar
event.target.classList.add('ge-avatar-fallback')
}
</script>
@@ -257,6 +261,7 @@ function onAvatarError(event) {
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
.user-identity { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); flex-shrink: 0; }
.ge-avatar-fallback { object-fit: contain; padding: 4px; background: #fff; }
.user-ident { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
.user-ident .username { margin-bottom: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-sso { font-size: 0.72rem; color: var(--text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@@ -96,12 +96,12 @@
<div class="form-group">
<label>Blueprint image (light theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="blueprintLight" :src="blueprintLight" class="wizard-map-thumb" alt="light blueprint" />
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
</div>
<div class="form-group">
<label>Blueprint image (dark theme)</label>
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="blueprintDark" :src="blueprintDark" class="wizard-map-thumb dark" alt="dark blueprint" />
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
</div>
<div class="form-row">
<div class="form-group">
@@ -166,6 +166,7 @@
</template>
<script setup>
import { withBase } from '../utils/basePath'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { settingsApi, pluginsApi, setupApi } from '../api'

View File

@@ -6,10 +6,10 @@
<template v-else-if="employee">
<div class="hero-card">
<div class="hero-image" v-if="employee.photourl">
<img :src="employee.photourl" :alt="fullName" />
<img :src="employee.photourl" :alt="fullName" @error="onPhotoError" />
</div>
<div class="hero-image placeholder" v-else>
<span class="initials">{{ initials }}</span>
<img :src="fallbackAvatar" alt="GE Aerospace" class="ge-avatar-fallback-lg" />
</div>
<div class="hero-content">
<h1 class="hero-title">{{ fullName }}</h1>
@@ -149,6 +149,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { withBase } from '../../utils/basePath'
import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '../../composables/toast'
@@ -192,6 +193,13 @@ const fullName = computed(() => {
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
})
const fallbackAvatar = withBase('/ge-monogram.svg')
function onPhotoError(event) {
event.target.src = fallbackAvatar
event.target.classList.add('ge-avatar-fallback-lg')
}
const initials = computed(() => {
if (!employee.value) return '?'
const first = employee.value.First_Name?.trim()?.[0] || ''
@@ -291,6 +299,12 @@ function formatDate(dateStr) {
color: white;
}
.ge-avatar-fallback-lg {
width: 60%;
height: 60%;
object-fit: contain;
opacity: 0.85;
}
.initials {
font-size: 3rem;
font-weight: 600;

View File

@@ -19,12 +19,19 @@
<p class="kiosk-prompt">Scan the barcode on the bin</p>
<p class="kiosk-hint">
No scanner?
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the code</a>
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the number</a>
</p>
<div v-if="manualEntry" class="manual-row">
<input v-model="manualCode" class="form-control" placeholder="3DP-0001"
@keydown.enter="lookupItem(manualCode)" />
<button class="btn btn-primary" @click="lookupItem(manualCode)">Go</button>
<div v-if="manualEntry" class="entry-panel">
<div class="entry-display" :class="{ empty: !manualCode }">
{{ manualCode || 'label number' }}
</div>
<TouchKeypad @digit="manualCode += $event"
@clear="manualCode = ''"
@backspace="manualCode = manualCode.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!manualCode"
@click="lookupItem(manualCode)">Look up</button>
<p class="kiosk-hint">Just the number from the label; letters are
added automatically.</p>
</div>
</section>
@@ -37,11 +44,16 @@
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">Scan your badge</p>
<div class="manual-row">
<input v-model="manualBadge" class="form-control" placeholder="or type your SSO"
@keydown.enter="acceptBadge(manualBadge)" />
<button class="btn btn-primary" @click="acceptBadge(manualBadge)">Next</button>
<p class="kiosk-prompt">Scan your badge or tap in your SSO</p>
<div class="entry-panel">
<div class="entry-display" :class="{ empty: !manualBadge }">
{{ manualBadge || 'SSO' }}
</div>
<TouchKeypad @digit="manualBadge += $event"
@clear="manualBadge = ''"
@backspace="manualBadge = manualBadge.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!manualBadge"
@click="acceptBadge(manualBadge)">Next</button>
</div>
</section>
@@ -55,14 +67,18 @@
</div>
</div>
<p class="kiosk-prompt">How many are you taking?</p>
<div class="quantity-display">{{ quantity || '0' }}</div>
<TouchKeypad @digit="quantity += $event"
@clear="quantity = ''"
@backspace="quantity = quantity.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
@click="submitTake">
{{ submitting ? 'Working...' : 'TAKE' }}
</button>
<div class="entry-panel">
<div class="entry-display" :class="{ empty: !quantity }">
{{ quantity || '0' }}
</div>
<TouchKeypad @digit="quantity += $event"
@clear="quantity = ''"
@backspace="quantity = quantity.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
@click="submitTake">
{{ submitting ? 'Working...' : 'TAKE' }}
</button>
</div>
</section>
<!-- done -->
@@ -96,7 +112,12 @@ let resetTimer = null
onMounted(focusWedge)
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusWedge() {
function focusWedge(event) {
// Tapping a visible input/button must keep it - only reclaim focus for
// the wedge scanner from dead space.
const tag = event?.target?.tagName
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|| tag === 'BUTTON' || tag === 'A') return
wedgeInput.value?.focus()
}
@@ -227,16 +248,48 @@ function reset() {
object-fit: cover;
border-radius: 0.4rem;
}
.quantity-display {
font-size: 3rem;
.entry-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem 2rem;
}
.entry-display {
width: 16.9rem;
box-sizing: border-box;
font-size: 2.4rem;
font-weight: 700;
min-width: 8rem;
font-variant-numeric: tabular-nums;
text-align: center;
border-bottom: 3px solid var(--primary);
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--bg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-display.empty {
color: var(--text-light);
font-weight: 400;
font-size: 1.4rem;
line-height: 2.4rem;
}
.take-button {
font-size: 1.5rem;
padding: 0.9rem 3.5rem;
font-size: 1.4rem;
padding: 0.85rem 0;
width: 16.9rem;
border-radius: 0.75rem;
}
.manual-row { display: flex; gap: 0.6rem; }
.manual-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
</style>

View File

@@ -47,6 +47,10 @@
<span class="info-label">Item code</span>
<span class="info-value">{{ item.itemcode }}</span>
</div>
<div class="info-row" v-if="item.gagelabtag">
<span class="info-label">Gage lab tag</span>
<span class="info-value">{{ item.gagelabtag }}</span>
</div>
<div class="info-row">
<span class="info-label">Bin location</span>
<span class="info-value">{{ item.binlocation || '-' }}</span>
@@ -81,42 +85,30 @@
</button>
</div>
<div v-if="fileError" class="error-message">{{ fileError }}</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Rev</th>
<th>File</th>
<th>Size</th>
<th>By</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="revision in files" :key="revision.fileid"
:class="{ 'current-revision': revision === files[0] }">
<td>{{ revision.revision }}</td>
<td>
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)">
{{ revision.filename }}
</a>
<span v-if="revision === files[0]" class="badge badge-success">current</span>
</td>
<td>{{ formatSize(revision.filesize) }}</td>
<td :title="revision.uploadeddate">{{ revision.uploadedby }}</td>
<td>{{ revision.uploadnote || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm"
@click="removeRevision(revision)">Delete</button>
</td>
</tr>
<tr v-if="files.length === 0">
<td colspan="6" class="empty-state">No print file uploaded yet</td>
</tr>
</tbody>
</table>
</div>
<ul class="file-revision-list">
<li v-for="revision in files" :key="revision.fileid"
:class="{ 'current-revision': revision === files[0] }">
<div class="file-main">
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)"
class="file-name">
{{ revision.filename }}
</a>
<span class="badge badge-secondary">rev {{ revision.revision }}</span>
<span v-if="revision === files[0]" class="badge badge-success">current</span>
</div>
<div class="file-meta">
{{ formatSize(revision.filesize) }} -
{{ revision.uploadedby }} -
{{ formatDate(revision.uploadeddate) }}
<span v-if="revision.uploadnote"> - {{ revision.uploadnote }}</span>
</div>
<button class="btn btn-secondary btn-sm file-delete"
@click="removeRevision(revision)">Delete</button>
</li>
<li v-if="files.length === 0" class="empty-state">
No print file uploaded yet
</li>
</ul>
</div>
<div class="section-card">
@@ -344,6 +336,40 @@ function formatDate(value) {
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.current-revision td { font-weight: 600; }
.file-revision-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.file-revision-list li {
position: relative;
border: 1px solid var(--border);
border-radius: 0.45rem;
padding: 0.6rem 5.5rem 0.6rem 0.8rem;
}
.file-revision-list li.current-revision { border-color: var(--primary); }
.file-main {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-name {
font-weight: 600;
overflow-wrap: anywhere;
}
.file-meta {
color: var(--text-light);
font-size: 0.85rem;
margin-top: 0.2rem;
}
.file-delete {
position: absolute;
top: 0.55rem;
right: 0.6rem;
}
.qty-in { color: var(--success); }
</style>

View File

@@ -32,9 +32,14 @@
<input v-model.number="form.lowstockthreshold" type="number" min="0"
class="form-control" />
</div>
<div v-if="isEdit" class="form-group">
<label>Item code</label>
<input :value="itemcode" type="text" class="form-control" disabled />
<div class="form-group">
<label>Gage lab asset tag</label>
<input v-model="form.gagelabtag" type="text" class="form-control"
placeholder="e.g. WJRP0117" />
<p class="form-hint">
Assigned by the gage lab; optional. The kiosk finds parts by
this tag or by the internal code.
</p>
</div>
</div>
@@ -81,12 +86,12 @@ const cancelTarget = computed(() =>
const form = ref({
itemname: '',
gagelabtag: '',
itemdescription: '',
lowstockthreshold: null,
binlocation: '',
printnotes: ''
})
const itemcode = ref('')
const imageurl = ref(null)
const saving = ref(false)
const error = ref('')
@@ -99,7 +104,6 @@ onMounted(async () => {
for (const key of Object.keys(form.value)) {
form.value[key] = item[key]
}
itemcode.value = item.itemcode
imageurl.value = item.imageurl
} catch (loadError) {
error.value = 'Could not load the item'
@@ -115,6 +119,7 @@ async function save() {
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
delete payload.lowstockthreshold
}
if (!payload.gagelabtag) payload.gagelabtag = ''
let printeditemid
if (isEdit.value) {
await printedpartsApi.update(route.params.id, payload)

View File

@@ -36,8 +36,8 @@
<td>{{ e.Team || '-' }}</td>
<td>{{ e.Role || '-' }}</td>
<td>
<img v-if="e.photourl" :src="e.photourl" alt="Photo" class="photo-thumb" />
<span v-else class="mono">-</span>
<img :src="e.photourl || fallbackAvatar" alt="Photo" class="photo-thumb"
:class="{ 'ge-thumb-fallback': !e.photourl }" />
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</button>
@@ -295,6 +295,7 @@ async function doImport() {
.form-row .form-group { flex: 1; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
.page-info { color: var(--text-light); font-size: 0.85rem; }
.ge-thumb-fallback { object-fit: contain; padding: 2px; background: #fff; }
.photo-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
.photo-manage { display: flex; align-items: center; gap: 1rem; }
.photo-thumb-lg { width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); }

View File

@@ -26,7 +26,7 @@
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_light" :src="settings.map_blueprint_light" class="map-thumb" alt="light blueprint" />
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
</label>
@@ -44,7 +44,7 @@
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_dark" :src="settings.map_blueprint_dark" class="map-thumb map-thumb-dark" alt="dark blueprint" />
<img v-if="settings.map_blueprint_dark" :src="withBase(settings.map_blueprint_dark)" class="map-thumb map-thumb-dark" alt="dark blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
</label>
@@ -88,6 +88,7 @@
</template>
<script setup>
import { withBase } from '../../utils/basePath'
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'

View File

@@ -43,9 +43,10 @@ EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
# URL prefix a served upload resolves to (self-hosted mode).
EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/'
# URL prefix external HR relative picture paths resolve under. The HR employees
# table stores Picture as a relative path (e.g. 'Support/210009518.png') that
# the site serves from /static/employees/; this matches the shopfloor feed.
# Fallback URL prefix external HR relative picture paths resolve under when
# the employee_photo_base_url setting is unset. Sites whose photos live on
# another host (e.g. the classic EmployeeDBAPP) set the setting to a full URL
# such as https://host/EmployeeDBAPP/images/ instead.
EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/'
@@ -96,7 +97,10 @@ def _external_photo_url(picture):
return None
if text.startswith(('http://', 'https://', '/')):
return text
return EMPLOYEE_PHOTO_STATIC_PREFIX + text
from shopdb.api import Setting
base = (Setting.get('employee_photo_base_url') or '').strip() \
or EMPLOYEE_PHOTO_STATIC_PREFIX
return base.rstrip('/') + '/' + text.lstrip('/')
def _hr_picture(sso):
@@ -112,6 +116,36 @@ def _hr_picture(sso):
return None
def resolve_employee_display_name(sso):
"""Display name ("First Last") for an SSO in either directory mode.
The shopfloor feed uses this as a live fallback when a notification has
no stored employeename (e.g. imported without the employee source).
None on any miss."""
if sso is None or not str(sso).isdigit():
return None
if _selfhosted():
emp = db.session.get(DirectoryEmployee, int(sso))
if emp:
return f'{emp.firstname} {emp.lastname}'.strip() or None
return None
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s',
(int(sso),))
row = cur.fetchone()
conn.close()
if row:
first = row.get('First_Name') or ''
last = row.get('Last_Name') or ''
return f'{first.strip()} {last.strip()}'.strip() or None
except Exception:
pass
return None
def resolve_employee_photo_url(sso, external_picture=None):
"""Single resolver both consumers share: the display photo URL for an SSO.

View File

@@ -79,6 +79,11 @@ class EmployeesPlugin(BasePlugin):
"""Employee directory DB connection. Host/name/user are settings the
wizard can edit; the password stays in .env (emitted, not stored)."""
return [
{'key': 'employee_photo_base_url', 'label': 'Photo base URL',
'type': 'text', 'secret': False,
'help': 'Where external HR relative Picture paths resolve, e.g. '
'https://host/EmployeeDBAPP/images/. Blank = this '
'site\'s /static/employees/.'},
{'key': 'employee_db_host', 'label': 'Employee DB host', 'type': 'text',
'secret': False, 'default': 'localhost',
'help': 'This DB must expose an "employees" table or view with columns '

View File

@@ -149,6 +149,16 @@ def _config_version():
return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12]
def _employee_name(sso):
"""Live directory name for an SSO; None on any miss. Used as the fallback
when a notification has no stored employeename (see the shopfloor feed)."""
try:
from plugins.employees.api.routes import resolve_employee_display_name
return resolve_employee_display_name(sso)
except Exception:
return None
def _employee_picture(sso):
"""Resolved display photo URL for an SSO, via the shared employees-plugin
resolver so kiosk cards match EmployeeDetail in both directory modes
@@ -723,7 +733,12 @@ def get_shopfloor_notifications():
result['employeepicture'] = employee_override.get('picture')
else:
result['employeesso'] = n.employeesso
result['employeename'] = n.employeename
# Stored name first (import/manual entry), else resolve live from
# the directory so shopdb-only imports still show names.
name = n.employeename
if not name and n.employeesso and ',' not in n.employeesso:
name = _employee_name(n.employeesso)
result['employeename'] = name
result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None
return result
@@ -742,7 +757,8 @@ def get_shopfloor_notifications():
return [
notification_to_shopfloor(n, {
'sso': sso,
'name': names[i] if i < len(names) else sso,
'name': (names[i] if i < len(names) and names[i] else None)
or _employee_name(sso) or sso,
'picture': _employee_picture(sso) if show_photo else None,
})
for i, sso in enumerate(ssos)

View File

@@ -1,8 +1,11 @@
"""Printedparts plugin API routes.
Reads are open (jwt optional) like every list surface; mutations arrive in
later stages with permission gates. The kiosk endpoints (unauthenticated by
explicit decision - see the proposal) also land later.
Access model: browsing the catalog (items, detail, file listings) requires
the printedparts.view permission; every mutation carries its own permission.
Deliberately open: the kiosk endpoints (decision record in the proposal),
the image serve and file download (fetched by <img> tags and anchor
downloads, which cannot carry a JWT header), and the reports (jwt-optional
like every other report in the product).
"""
from flask import Blueprint, request
@@ -26,7 +29,8 @@ printedparts_bp = Blueprint('printedparts', __name__)
@printedparts_bp.route('/items', methods=['GET'])
@jwt_required(optional=True)
@jwt_required()
@require_permission('printedparts.view')
def list_items():
"""List printed items, paginated; search + low-stock filter."""
page, per_page = get_pagination_params(request)
@@ -37,6 +41,7 @@ def list_items():
like = f'%{search}%'
query = query.filter(or_(
PrintedItem.itemcode.ilike(like),
PrintedItem.gagelabtag.ilike(like),
PrintedItem.itemname.ilike(like),
PrintedItem.itemdescription.ilike(like),
PrintedItem.binlocation.ilike(like),
@@ -51,7 +56,8 @@ def list_items():
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
@jwt_required(optional=True)
@jwt_required()
@require_permission('printedparts.view')
def get_item(item_id: int):
"""Get one printed item with its recent transactions."""
item = db.session.get(PrintedItem, item_id)
@@ -98,18 +104,28 @@ def _mint_itemcode(item):
@jwt_required()
@require_permission('printedparts.create')
def create_item():
"""Create a printed item; the itemcode is minted from the row id."""
"""Create a printed item.
The internal itemcode is always auto-minted; the OPTIONAL gagelabtag
carries the gage lab's assigned WJRP asset number (unique-checked)."""
data = request.get_json() or {}
itemname = (data.get('itemname') or '').strip()
if not itemname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
if gagelabtag and PrintedItem.query.filter_by(gagelabtag=gagelabtag).first():
return error_response(ErrorCodes.CONFLICT,
f'Gage lab tag {gagelabtag} is already in use',
http_code=409)
threshold = data.get('lowstockthreshold')
if threshold is None:
threshold = int(Setting.get('printedparts_default_threshold') or 5)
item = PrintedItem(
itemname=itemname,
gagelabtag=gagelabtag or None,
itemdescription=data.get('itemdescription'),
lowstockthreshold=threshold,
binlocation=data.get('binlocation'),
@@ -138,6 +154,18 @@ def update_item(item_id: int):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'quantityonhand is ledger-managed; use restock or adjust')
if 'gagelabtag' in data:
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
if gagelabtag:
clash = PrintedItem.query.filter(
PrintedItem.gagelabtag == gagelabtag,
PrintedItem.printeditemid != item.printeditemid).first()
if clash:
return error_response(
ErrorCodes.CONFLICT,
f'Gage lab tag {gagelabtag} is already in use',
http_code=409)
item.gagelabtag = gagelabtag or None
for field in EDITABLE_FIELDS:
if field in data:
setattr(item, field, data[field])
@@ -381,12 +409,37 @@ def adjust_item(item_id: int):
# physically rate-limited. It can reduce stock of an active item and nothing
# else; identity comes from the badge resolved server-side, never the client.
def _kiosk_find_item(itemcode):
"""Resolve a scanned or typed code to an active item.
Accepts the full code (WJRP0042) or bare digits from the touch keypad
(42 -> prefix + zero-pad), so manual entry never needs letters."""
scanned = (itemcode or '').strip().upper()
item = PrintedItem.query.filter(
or_(PrintedItem.itemcode == scanned,
PrintedItem.gagelabtag == scanned),
PrintedItem.isactive == True).first()
if not item and scanned.isdigit():
# Bare digits from the touch keypad match the NUMBER inside either
# identifier (internal code or gage-lab tag). Small catalog: scan
# actives and compare numeric tails; only a UNIQUE match counts.
wanted = int(scanned)
matches = []
for candidate in PrintedItem.query.filter_by(isactive=True).all():
for value in (candidate.itemcode, candidate.gagelabtag):
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
if tail and int(tail) == wanted:
matches.append(candidate)
break
if len(matches) == 1:
item = matches[0]
return item
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
def kiosk_item(itemcode):
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
item = PrintedItem.query.filter(
PrintedItem.itemcode == itemcode.strip(),
PrintedItem.isactive == True).first()
item = _kiosk_find_item(itemcode)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
'No part matches that barcode', http_code=404)
@@ -398,9 +451,7 @@ def kiosk_take():
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
data = request.get_json() or {}
item = PrintedItem.query.filter(
PrintedItem.itemcode == (data.get('itemcode') or '').strip(),
PrintedItem.isactive == True).first()
item = _kiosk_find_item(data.get('itemcode'))
if not item:
return error_response(ErrorCodes.NOT_FOUND,
'No part matches that barcode', http_code=404)
@@ -564,7 +615,8 @@ def _uploader_name():
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
@jwt_required(optional=True)
@jwt_required()
@require_permission('printedparts.view')
def list_item_files(item_id: int):
"""Revision history, newest first."""
files = (PrintedItemFile.query.filter_by(printeditemid=item_id)

View File

@@ -0,0 +1,27 @@
"""Add printeditems.gagelabtag: the gage-lab assigned asset tag.
The gage lab issues WJRP asset numbers for printed parts; the internal
itemcode stays auto-generated, and this optional unique tag carries the
lab's number. The kiosk resolves scans/typed digits against both.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'printedparts0003gagetag'
down_revision = 'printedparts0002files'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('printeditems',
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
['gagelabtag'], unique=True)
def downgrade():
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
op.drop_column('printeditems', 'gagelabtag')

View File

@@ -28,6 +28,8 @@ class PrintedItem(BaseModel):
printeditemid = db.Column(db.Integer, primary_key=True)
itemcode = db.Column(db.String(20), unique=True, index=True,
comment='Generated bin-label code, e.g. 3DP-0042')
gagelabtag = db.Column(db.String(50), unique=True, index=True,
comment='Gage-lab assigned asset tag, e.g. WJRP0117')
itemname = db.Column(db.String(120), nullable=False)
itemdescription = db.Column(db.String(500))
imageurl = db.Column(db.String(255))
@@ -48,6 +50,7 @@ class PrintedItem(BaseModel):
return {
'printeditemid': self.printeditemid,
'itemcode': self.itemcode,
'gagelabtag': self.gagelabtag,
'itemname': self.itemname,
'itemdescription': self.itemdescription,
'imageurl': self.imageurl,

View File

@@ -8,18 +8,25 @@ cross-plugin imports break the shopdb.api-only contract):
scanners emit this shape)
- anything else -> unresolvable
Names come from the employees plugin's self-hosted directory, looked up by
SSO. The directory carries no PayNo column, so PayNo badges resolve only when
the wrapped digits are themselves the SSO (true at sites whose badges encode
the SSO); otherwise they fall to the unknown-badge policy.
Name lookup honors the site's employee directory mode (the same setting the
employees/usb plugins use):
- selfhosted: the employees plugin's directoryemployees table, by SSO. The
table has no PayNo column, so a PayNo badge only resolves when the wrapped
digits are themselves the SSO.
- external: the HR directory via employee_connection() - SSO badges by SSO,
PayNo badges by PayNo (which also recovers the real SSO to record).
Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge
with no directory match; 'allow' records the SSO with an empty name.
"""
import logging
import re
from shopdb.api import Setting
from shopdb.api import Setting, employee_connection
logger = logging.getLogger(__name__)
_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
@@ -28,17 +35,59 @@ class BadgeError(ValueError):
"""Raised when a badge cannot be accepted under the site policy."""
def _directory_name(sso):
"""Best-effort display name from the employees plugin directory."""
def _parse_badge(badge):
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
if badge.isdigit():
return 'sso', badge
match = _PAYNO_BADGE.match(badge)
if match:
return 'payno', match.group(1)
raise BadgeError('Unrecognized badge format')
def _selfhosted_lookup(digits):
"""(sso, name) from the employees plugin directory, or None."""
try:
from plugins.employees.models import DirectoryEmployee
from shopdb.api import db
if sso and str(sso).isdigit():
employee = db.session.get(DirectoryEmployee, int(sso))
if employee:
return f'{employee.firstname} {employee.lastname}'.strip()
employee = db.session.get(DirectoryEmployee, int(digits))
if employee:
return digits, f'{employee.firstname} {employee.lastname}'.strip()
except Exception:
pass
logger.exception('Self-hosted directory lookup failed for %s', digits)
return None
def _external_lookup(kind, digits):
"""(sso, name) from the HR directory, or None. PayNo badges resolve to
the employee's real SSO."""
try:
conn = employee_connection()
except Exception:
logger.exception('HR directory connection failed')
return None
try:
with conn.cursor() as cursor:
column = 'SSO' if kind == 'sso' else 'PayNo'
cursor.execute(
f'SELECT SSO, First_Name, Last_Name FROM employees '
f'WHERE {column} = %s', (digits,))
row = cursor.fetchone()
if row:
sso = str(row[0] if not isinstance(row, dict) else row['SSO'])
first = row[1] if not isinstance(row, dict) else row['First_Name']
last = row[2] if not isinstance(row, dict) else row['Last_Name']
return sso, f'{(first or "").strip()} {(last or "").strip()}'.strip()
except Exception:
logger.exception('HR directory lookup failed for %s %s', kind, digits)
finally:
try:
conn.close()
except Exception:
pass
return None
@@ -48,22 +97,17 @@ def resolve_badge(badge):
Raises BadgeError with a kiosk-displayable message when the badge shape is
unrecognized or the policy denies an unmatched badge.
"""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
kind, digits = _parse_badge(badge)
if badge.isdigit():
sso = badge
mode = (Setting.get('employee_directory_mode') or 'selfhosted').lower()
if mode == 'external':
resolved = _external_lookup(kind, digits)
else:
match = _PAYNO_BADGE.match(badge)
if not match:
raise BadgeError('Unrecognized badge format')
sso = match.group(1)
resolved = _selfhosted_lookup(digits)
name = _directory_name(sso)
if name is None:
if resolved is None:
policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
if policy != 'allow':
raise BadgeError('Badge not recognized - see the parts team')
return sso, ''
return sso, name
return digits, ''
return resolved

View File

@@ -1,389 +1,399 @@
"""User management API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET'])
@jwt_required()
@require_role('admin')
def list_users():
"""List all users."""
users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users])
@users_bp.route('/<int:userid>', methods=['GET'])
@jwt_required()
def get_user(userid: int):
"""Get a single user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
return success_response(user_to_dict(user))
@users_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_user():
"""Create a new user."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
# Validate required fields
if not data.get('username'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
if not data.get('email'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
if not data.get('password'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
# Check uniqueness
if User.query.filter_by(username=data['username']).first():
return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True)
user = User(
username=data['username'],
email=data['email'],
passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'),
lastname=data.get('lastname'),
isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange)
)
# Assign roles
role_ids = data.get('roles', [])
if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
user.roles = roles
db.session.add(user)
# Audit log
AuditLog.log('created', 'User', entityname=user.username)
db.session.commit()
# Best-effort welcome email. The account exists regardless of mail outcome;
# a failure is surfaced as a warning in the response, never a hard error.
warning = None
if data.get('sendwelcome', True) and user.email:
sent = _send_welcome_email(user, data['password'])
if not sent:
warning = 'User created but the welcome email could not be sent.'
payload = user_to_dict(user)
if warning:
payload['warning'] = warning
return success_response(payload, message='User created', http_code=201)
def _send_welcome_email(user, temp_password):
"""Send a new-user welcome email with sign-in details. Returns True on send.
Best-effort: any failure (including email being disabled) returns False so
the caller can surface a soft warning without failing user creation.
"""
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.mailer import render_email, send_email
settings = get_cached_settings() or {}
facility = settings.get('facility_name') or 'ShopDB'
base_url = (settings.get('site_base_url') or '').rstrip('/')
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
body = (
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
f'<td><strong>{user.username}</strong></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
f'<td><code>{temp_password}</code></td></tr>'
'</table>'
f'<p>Sign in at {login_link}. You will be asked to set a new password '
'the first time you log in.</p>'
)
html, text = render_email(f'Welcome to {facility}', body)
return send_email(user.email, f'Your {facility} account', html, text=text)
@users_bp.route('/<int:userid>', methods=['PUT'])
@jwt_required()
def update_user(userid: int):
"""Update a user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
changes = {}
# Update fields
if 'email' in data and data['email'] != user.email:
if User.query.filter(User.email == data['email'], User.userid != userid).first():
return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email']
if 'firstname' in data:
if data['firstname'] != user.firstname:
changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
user.firstname = data['firstname']
if 'lastname' in data:
if data['lastname'] != user.lastname:
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname']
# Admin-only fields (inline: gates a subset of fields on a shared route)
if current_user.hasrole('admin'):
if 'isactive' in data:
if data['isactive'] != user.isactive:
changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
user.isactive = data['isactive']
if 'roles' in data:
old_roles = [r.rolename for r in user.roles]
roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
new_roles = [r.rolename for r in roles]
if set(old_roles) != set(new_roles):
changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles
# Unlock user
if data.get('unlock'):
user.lockeduntil = None
user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False}
# Password change
if 'password' in data and data['password']:
user.passwordhash = generate_password_hash(data['password'])
changes['password'] = {'old': '***', 'new': '***'}
if changes:
AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
db.session.commit()
return success_response(user_to_dict(user), message='User updated')
@users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_user(userid: int):
"""Delete a user."""
if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username
db.session.delete(user)
AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
db.session.commit()
return success_response(None, message='User deleted')
# Permissions endpoints
@users_bp.route('/permissions', methods=['GET'])
@jwt_required()
def list_permissions():
"""List assignable permissions grouped by category.
Driven by full_permission_catalog() (core plus ENABLED plugins) so a
disabled plugin's permissions drop out of the role grid. Roles assign by
name; the permissionid comes from the seeded Permission row when present.
"""
idbyname = {p.name: p.permissionid for p in Permission.query.all()}
catalog = full_permission_catalog()
catalog.sort(key=lambda e: (e[2], e[0]))
grouped = {}
flat = []
for name, description, category in catalog:
entry = {
'permissionid': idbyname.get(name),
'name': name,
'description': description,
}
grouped.setdefault(category, []).append(entry)
flat.append({**entry, 'category': category})
return success_response({'permissions': flat, 'grouped': grouped})
# Roles endpoints
@users_bp.route('/roles', methods=['GET'])
@jwt_required()
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
'description': r.description,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
} for r in roles])
@users_bp.route('/roles', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_role():
"""Create a new role."""
data = request.get_json()
if not data or not data.get('rolename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
if Role.query.filter_by(rolename=data['rolename']).first():
return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
role = Role(
rolename=data['rolename'],
description=data.get('description')
)
# Assign permissions
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
role.permissions = perms
db.session.add(role)
AuditLog.log('created', 'Role', entityname=role.rolename)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role created', http_code=201)
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_role(roleid: int):
"""Update a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
# Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
data = request.get_json()
changes = {}
if 'description' in data:
if data['description'] != role.description:
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
if set(old_perms) != set(new_perms):
changes['permissions'] = {'old': old_perms, 'new': new_perms}
role.permissions = perms
if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role updated')
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_role(roleid: int):
"""Delete a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
rolename = role.rolename
db.session.delete(role)
AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
db.session.commit()
return success_response(None, message='Role deleted')
def user_to_dict(user: User) -> dict:
"""Convert user to dict for API response."""
return {
'userid': user.userid,
'username': user.username,
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}
"""User management API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET'])
@jwt_required()
@require_role('admin')
def list_users():
"""List all users."""
users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users])
@users_bp.route('/<int:userid>', methods=['GET'])
@jwt_required()
def get_user(userid: int):
"""Get a single user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
return success_response(user_to_dict(user))
@users_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_user():
"""Create a new user."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
# Validate required fields
if not data.get('username'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
if not data.get('email'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
if not data.get('password'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
# Check uniqueness
if User.query.filter_by(username=data['username']).first():
return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True)
user = User(
username=data['username'],
email=data['email'],
passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'),
lastname=data.get('lastname'),
isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange)
)
# Assign roles
role_ids = data.get('roles', [])
if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
user.roles = roles
db.session.add(user)
# Audit log
AuditLog.log('created', 'User', entityname=user.username)
db.session.commit()
# Best-effort welcome email. The account exists regardless of mail outcome;
# a failure is surfaced as a warning in the response, never a hard error.
warning = None
if data.get('sendwelcome', True) and user.email:
sent = _send_welcome_email(user, data['password'])
if not sent:
warning = 'User created but the welcome email could not be sent.'
payload = user_to_dict(user)
if warning:
payload['warning'] = warning
return success_response(payload, message='User created', http_code=201)
def _send_welcome_email(user, temp_password):
"""Send a new-user welcome email with sign-in details. Returns True on send.
Best-effort: any failure (including email being disabled) returns False so
the caller can surface a soft warning without failing user creation.
"""
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.mailer import render_email, send_email
settings = get_cached_settings() or {}
facility = settings.get('facility_name') or 'ShopDB'
base_url = (settings.get('site_base_url') or '').rstrip('/')
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
body = (
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
f'<td><strong>{user.username}</strong></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
f'<td><code>{temp_password}</code></td></tr>'
'</table>'
f'<p>Sign in at {login_link}. You will be asked to set a new password '
'the first time you log in.</p>'
)
html, text = render_email(f'Welcome to {facility}', body)
return send_email(user.email, f'Your {facility} account', html, text=text)
@users_bp.route('/<int:userid>', methods=['PUT'])
@jwt_required()
def update_user(userid: int):
"""Update a user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
changes = {}
# Update fields
if 'email' in data and data['email'] != user.email:
if User.query.filter(User.email == data['email'], User.userid != userid).first():
return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email']
if 'firstname' in data:
if data['firstname'] != user.firstname:
changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
user.firstname = data['firstname']
if 'lastname' in data:
if data['lastname'] != user.lastname:
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname']
# Admin-only fields (inline: gates a subset of fields on a shared route)
if current_user.hasrole('admin'):
if 'isactive' in data:
if data['isactive'] != user.isactive:
changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
user.isactive = data['isactive']
if 'roles' in data:
old_roles = [r.rolename for r in user.roles]
roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
new_roles = [r.rolename for r in roles]
if set(old_roles) != set(new_roles):
changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles
# Unlock user
if data.get('unlock'):
user.lockeduntil = None
user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False}
# Password change
if 'password' in data and data['password']:
user.passwordhash = generate_password_hash(data['password'])
changes['password'] = {'old': '***', 'new': '***'}
if changes:
AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
db.session.commit()
return success_response(user_to_dict(user), message='User updated')
@users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_user(userid: int):
"""Delete a user."""
if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username
# Rows that reference the user would otherwise block the delete:
# revoke their API tokens outright, and DETACH their audit history
# (userid -> NULL) - the log rows themselves are kept, entityname and
# details still tell the story.
from shopdb.core.models import ApiToken
ApiToken.query.filter_by(userid=userid).delete(synchronize_session=False)
AuditLog.query.filter_by(userid=userid).update(
{'userid': None}, synchronize_session=False)
db.session.delete(user)
AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
db.session.commit()
return success_response(None, message='User deleted')
# Permissions endpoints
@users_bp.route('/permissions', methods=['GET'])
@jwt_required()
def list_permissions():
"""List assignable permissions grouped by category.
Driven by full_permission_catalog() (core plus ENABLED plugins) so a
disabled plugin's permissions drop out of the role grid. Roles assign by
name; the permissionid comes from the seeded Permission row when present.
"""
idbyname = {p.name: p.permissionid for p in Permission.query.all()}
catalog = full_permission_catalog()
catalog.sort(key=lambda e: (e[2], e[0]))
grouped = {}
flat = []
for name, description, category in catalog:
entry = {
'permissionid': idbyname.get(name),
'name': name,
'description': description,
}
grouped.setdefault(category, []).append(entry)
flat.append({**entry, 'category': category})
return success_response({'permissions': flat, 'grouped': grouped})
# Roles endpoints
@users_bp.route('/roles', methods=['GET'])
@jwt_required()
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
'description': r.description,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
} for r in roles])
@users_bp.route('/roles', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_role():
"""Create a new role."""
data = request.get_json()
if not data or not data.get('rolename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
if Role.query.filter_by(rolename=data['rolename']).first():
return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
role = Role(
rolename=data['rolename'],
description=data.get('description')
)
# Assign permissions
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
role.permissions = perms
db.session.add(role)
AuditLog.log('created', 'Role', entityname=role.rolename)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role created', http_code=201)
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_role(roleid: int):
"""Update a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
# Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json():
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
data = request.get_json()
changes = {}
if 'description' in data:
if data['description'] != role.description:
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
if set(old_perms) != set(new_perms):
changes['permissions'] = {'old': old_perms, 'new': new_perms}
role.permissions = perms
if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
db.session.commit()
return success_response({
'roleid': role.roleid,
'rolename': role.rolename,
'description': role.description,
'permissions': [p.name for p in role.permissions]
}, message='Role updated')
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_role(roleid: int):
"""Delete a role."""
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
rolename = role.rolename
db.session.delete(role)
AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
db.session.commit()
return success_response(None, message='Role deleted')
def user_to_dict(user: User) -> dict:
"""Convert user to dict for API response."""
return {
'userid': user.userid,
'username': user.username,
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}

View File

@@ -0,0 +1,29 @@
def test_delete_user_with_tokens_and_audit_history(client, auth_headers, app, db):
"""Deleting a user revokes their API tokens and detaches (not deletes)
their audit rows - the importer-user case."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, ApiToken, AuditLog
with app.app_context():
user = User(username='importer2', email='importer2@test.local',
passwordhash=generate_password_hash('x'), isactive=True)
db.session.add(user)
db.session.flush()
db.session.add(ApiToken(userid=user.userid, name='import token',
tokenprefix='deadbeef', tokenhash='x' * 64))
db.session.add(AuditLog(userid=user.userid, action='created',
entitytype='Asset', entityid=1,
entityname='imported thing'))
db.session.commit()
userid = user.userid
response = client.delete(f'/api/users/{userid}', headers=auth_headers)
assert response.status_code == 200, response.get_json()
with app.app_context():
assert db.session.get(User, userid) is None
assert ApiToken.query.filter_by(userid=userid).count() == 0
detached = AuditLog.query.filter_by(entityname='imported thing').one()
assert detached.userid is None

View File

@@ -55,7 +55,7 @@ EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
# printedparts is post-cutover: its 0001 really creates its tables.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0002files'
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0003gagetag'
# notifications indexes businessunitid on top of its anchor.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'

View File

@@ -121,6 +121,15 @@ def test_anonymous_cannot_mutate(client, item):
json={'quantity': 1, 'badge': '1'}).status_code == 401
def test_catalog_reads_require_view_permission(client, member_headers, item):
"""Browsing the catalog is printedparts.view-gated; the kiosk stays open."""
assert client.get('/api/printedparts/items').status_code == 401
assert client.get(f'/api/printedparts/items/{item}').status_code == 401
assert client.get('/api/printedparts/items',
headers=member_headers).status_code == 403
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
def test_member_without_permission_gets_403(client, member_headers, item):
"""Authentication alone is not authorization: a role-less user is denied."""
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
@@ -248,12 +257,14 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
assert client.delete(f'/api/printedparts/items/{item}',
headers=auth_headers).status_code == 200
listed = client.get('/api/printedparts/items').get_json()['data']
listed = client.get('/api/printedparts/items',
headers=auth_headers).get_json()['data']
assert all(row['printeditemid'] != item for row in listed)
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
assert kiosk.status_code == 404
including = client.get('/api/printedparts/items?active=false')
including = client.get('/api/printedparts/items?active=false',
headers=auth_headers)
assert any(row['printeditemid'] == item
for row in including.get_json()['data'])
@@ -286,7 +297,8 @@ def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path
content_type='multipart/form-data')
assert bad.status_code == 400
listing = client.get(f'/api/printedparts/items/{item}/files').get_json()['data']
listing = client.get(f'/api/printedparts/items/{item}/files',
headers=auth_headers).get_json()['data']
assert [f['revision'] for f in listing] == [2, 1]
fileid = listing[1]['fileid']
@@ -326,3 +338,32 @@ def test_alert_role_members_receive(client, auth_headers, app, item,
'badge': directory_employee, 'quantity': 6})
assert take.status_code == 200
assert captured['to'] == ['crewone@site.test']
def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
"""The internal code stays auto-minted; the gage-lab tag is optional,
unique, searchable, and the kiosk resolves it (exact and bare digits)."""
created = client.post('/api/printedparts/items',
json={'itemname': 'Gage block holder',
'gagelabtag': 'wjrp0117'},
headers=auth_headers)
assert created.status_code == 201
data = created.get_json()['data']
assert data['gagelabtag'] == 'WJRP0117'
assert data['itemcode'].startswith('3DP') # internal code untouched
duplicate = client.post('/api/printedparts/items',
json={'itemname': 'Other',
'gagelabtag': 'WJRP0117'},
headers=auth_headers)
assert duplicate.status_code == 409
searched = client.get('/api/printedparts/items?search=WJRP0117',
headers=auth_headers).get_json()['data']
assert len(searched) == 1
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117')
assert by_tag.status_code == 200
by_digits = client.get('/api/printedparts/kiosk/item/117')
assert by_digits.status_code == 200
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'

View File

@@ -70,3 +70,32 @@ def test_single_employee_recognition_stays_single_card(client, db):
current = resp.get_json()['data']['current']
assert len(current) == 1
assert current[0]['employeesso'] == '111'
def test_shopfloor_names_resolve_live_when_not_stored(client, app, db):
"""A notification imported without employeename shows the directory name,
not the bare SSO - single and split-per-employee paths both."""
from plugins.employees.models import DirectoryEmployee
from plugins.notifications.models import Notification, NotificationType
from shopdb.core.models import Setting
with app.app_context():
Setting.set('employee_directory_mode', 'selfhosted',
valuetype='string', category='employees')
db.session.add(DirectoryEmployee(
sso=502000777, firstname='Recert', lastname='Person'))
ntype = NotificationType(typename='Recertification',
typecolor='recertification',
splitperemployee=True)
db.session.add(ntype)
db.session.flush()
db.session.add(Notification(
notificationtypeid=ntype.notificationtypeid,
notification='Recert due', isshopfloor=True,
employeesso='502000777', employeename=None))
db.session.commit()
feed = client.get('/api/notifications/shopfloor').get_json()['data']
cards = feed['current'] + feed['upcoming']
card = next(c for c in cards if c['notification'] == 'Recert due')
assert card['employeename'] == 'Recert Person'