Add email sending (service + 3 flows) and a general asset label generator
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Email: a stdlib SMTP mailer (settings-first config, graceful no-op when
unconfigured), a test-email endpoint wired to the Email settings page,
forced first-login password change (users.mustchangepassword, migration
7d23, /change-password flow), new-user welcome mail, and on-demand
report/alert delivery (POST /api/reports/email + Email Report buttons)
with an external-cron-with-a-scoped-PAT path documented for automation.
All tests patch smtplib - no network.

Labels: a shared /print/asset-label/<type>/<id> view any asset detail
page opens - card or plain style, QR or barcode, configurable encoding.
Per-type qr_target_* templates plus label_default_style/codetype/encodes
settings on the Printing page. Measuring-tool labels default to encoding
their inspection-operation code (derived from the location name, e.g.
0615), so every tool in an area shares the area code - verified by
decoding the rendered QR. Machine labels default to the machine number;
blank-serial handled gracefully.

808 tests pass; both features verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 11:58:30 -04:00
parent 7d309aabeb
commit a846587f39
34 changed files with 1819 additions and 12 deletions

View File

@@ -12,6 +12,49 @@ ADR-007 and ADR-002.
### Added
- Email sending. A stdlib-only mail service (`shopdb/utils/mailer.py`;
`smtplib`/`ssl`/`email`) reads the existing `email` SMTP settings
settings-first with an `SMTP_*` env fallback, sends multipart HTML+text, and
is a graceful no-op (logs a warning, returns False) when email is disabled or
the host is unset. The SMTP password is never logged. Three flows use it:
(1) New-user welcome + forced first-login password change. Admin-created users
(POST `/api/users`) are flagged `mustchangepassword` (new `users` column,
migration `7d23_user_mustchangepassword`, default false) and sent a
best-effort welcome email with the facility name, username, temporary
password, and sign-in link; the account is created even if mail fails
(response carries a `warning`). Login returns `mustchangepassword`; the
frontend forces the user through a new `/change-password` view (POST
`/api/auth/change-password`, jwt-guarded) before the app, and changing the
password clears the flag and resets lockout counters. A self-service "Change
password" entry is also available from the user menu.
(2) Test email. POST `/api/settings/test-email` (settings.edit) sends a probe
and surfaces any SMTP error with the password scrubbed; wires up the Email /
SMTP settings page "Send Test Email" button.
(3) On-demand alert/report delivery. POST `/api/reports/email`
(reports.export) mails `{subject, columns, rows}` as an HTML table to a
supplied recipient or the site `alert_recipients`; an "Email report" button on
the Warranty and Toner report pages posts the loaded rows. There is no
scheduler: automation is an external cron hitting the endpoint with a scoped
API token (PAT). Documented in `docs/CONFIG.md`.
- Shared asset label/code generator: a single `/print/asset-label/<assettype>/<id>`
page (public, like the other `/print/*` routes) that any asset detail page
opens via a "Print Label" button (machines, computers, printers, network
devices, measuring tools). A no-print controls panel toggles the layout
(`card` badge vs `plain` code-only), the code type (QR vs CODE128 barcode),
and what the code encodes: the asset page link, asset number, serial number,
a per-type custom target template, or - for measuring tools by default - the
tool's inspection location code so every tool at one operation shares one
code (e.g. `0615`). QR codes reuse the shared logo-overlay renderer. New
`printing` settings seed and surface on the Printing & Labels settings page:
`qr_target_machine`, `qr_target_computer`, `qr_target_network_device`,
`qr_target_measuring_tool`; `label_default_style` (default `card`) and
`label_default_codetype` (default `qr`); and a per-asset-type default for what
the code encodes, `label_default_encodes_<type>` (machines default to their
machine number, measuring tools to their inspection location code, the rest to
a page link), all overridable on the label page itself. When the chosen field
has no value (e.g. serial number on an asset with none), the label states so
instead of rendering an empty code. Asset payloads now carry a derived
`locationcode` (leading token of the resolved own/inherited location name).
- 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

View File

@@ -170,6 +170,19 @@ them under `instance/branding/`.
| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. |
| `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. |
| `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). |
| `qr_target_machine` | (empty) | Custom URL template for machine labels. Blank = link to the machine page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_computer` | (empty) | Custom URL template for computer labels. Blank = link to the computer page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_network_device` | (empty) | Custom URL template for network-device labels. Blank = link to the device page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. |
| `qr_target_measuring_tool` | (empty) | Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`, `{locationcode}`, `{locationname}`. |
| `label_default_style` | `card` | Default asset-label layout used when a label first opens: `card` (badge with image and identity) or `plain` (just the code and a caption). |
| `label_default_codetype` | `qr` | Default asset-label code type used when a label first opens: `qr` (QR code) or `barcode` (CODE128). |
| `label_default_encodes_machine` | `assetnumber` | What a machine label encodes by default. |
| `label_default_encodes_computer` | `assetpage` | What a computer label encodes by default. |
| `label_default_encodes_printer` | `assetpage` | What a printer label encodes by default. |
| `label_default_encodes_network_device` | `assetpage` | What a network-device label encodes by default. |
| `label_default_encodes_measuring_tool` | `location` | What a measuring-tool label encodes by default. Values across these five: `assetpage`, `assetnumber`, `serialnumber`, `location` (measuring tools only), or `custom`. Overridable on the label page. |
The shared asset-label generator lives at `/print/asset-label/<assettype>/<id>` (public, like the other `/print/*` pages; `assettype` is one of `machine`, `computer`, `printer`, `network_device`, `measuring_tool`, and `id` is the asset's plugin id). It can encode the asset page link, the asset number, the serial number, a custom `qr_target_<type>` template, or - for measuring tools by default - the asset's inspection location code (the leading token of the location name, e.g. `0615`). A measuring tool with no location falls back to its asset page.
### map
@@ -210,7 +223,48 @@ them under `instance/branding/`.
| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. |
| `smtp_from_address` | (empty) | From address for outgoing email. |
| `smtp_from_name` | `ShopDB` | From name for outgoing email. |
| `alert_recipients` | (empty) | Default alert recipients (comma-separated). |
| `alert_recipients` | (empty) | Default alert/report recipients (comma-separated). |
#### Email flows and delivery model
The mail service (`shopdb/utils/mailer.py`, stdlib `smtplib`/`ssl`/`email`
only) reads the keys above settings-first via the cached settings map, with an
environment-variable fallback (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`,
`SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`,
`SMTP_ALERT_RECIPIENTS`, `SMTP_ENABLED`) applied only when any `SMTP_*` env var
is present. When `smtp_enabled` is false or `smtp_host` is blank, every send is
a graceful no-op that logs a warning and returns without error, so an
unconfigured site never crashes. The SMTP password is never logged.
Three flows use it:
- Welcome email. When an admin creates a user (POST `/api/users`), the account
is flagged `mustchangepassword` and a best-effort welcome email is sent with
the facility name (`facility_name`), the username, the temporary password,
and the sign-in link (`site_base_url` + `/login`). Mail is best-effort: the
user is created even if the send fails (the response carries a `warning`). On
first login the API returns `mustchangepassword: true`; the frontend forces
the user through `/change-password` (POST `/api/auth/change-password`) before
the app. Changing the password clears the flag and resets lockout counters.
Set `sendwelcome: false` or `mustchangepassword: false` in the create body to
opt out.
- Test email. POST `/api/settings/test-email` (settings.edit) sends a probe to
the supplied `to` (or `alert_recipients`). The Email / SMTP settings page
"Send Test Email" button calls it and shows the result; a real SMTP error is
surfaced with the password scrubbed out.
- Alerts and report delivery (on-demand). POST `/api/reports/email`
(reports.export) takes `{subject, columns, rows, intro?, to?}` and mails the
rows as an HTML table. Recipients default to `alert_recipients` when `to` is
omitted, so the same endpoint serves both report delivery and alerts. Report
pages (Warranty, Toner) carry an "Email report" button that posts the rows
they already loaded.
There is NO scheduler in this app: sending is on-demand. To automate a
recurring send (e.g. a nightly warranty digest), point an external cron job
at `/api/reports/email` using an API token (PAT) scoped to `reports.export`.
See `docs/IMPORT-API.md` for the token model.
### audit

View File

@@ -60,6 +60,9 @@ export const authApi = {
return api.post('/auth/refresh', {}, {
headers: { Authorization: `Bearer ${refreshToken}` }
})
},
changePassword(payload) {
return api.post('/auth/change-password', payload)
}
}
@@ -714,6 +717,11 @@ export const reportsApi = {
},
pcRelationships(params = {}) {
return api.get('/reports/pc-relationships', { params })
},
// On-demand report delivery: email the given rows as an HTML table.
// Recipients default to the site Alert Recipients when `to` is omitted.
email(payload) {
return api.post('/reports/email', payload)
}
}
@@ -822,6 +830,9 @@ export const settingsApi = {
update(key, value) {
return api.put(`/settings/${key}`, { value })
},
testEmail(to) {
return api.post('/settings/test-email', { to })
},
create(data) {
return api.post('/settings', data)
},

View File

@@ -0,0 +1,53 @@
<template>
<button class="btn btn-secondary" :disabled="sending" @click="emailReport">
{{ sending ? 'Sending...' : 'Email report' }}
</button>
</template>
<script setup>
import { ref } from 'vue'
import { reportsApi } from '../api'
import { useToast } from '../composables/toast'
import { apiError } from '../utils/apiError'
// On-demand report delivery. Emails the given rows as an HTML table to the
// site's Alert Recipients (or an explicit `to`). Automatic/scheduled sending is
// out of scope for this app; point an external cron at POST /api/reports/email
// with an API token to automate.
const props = defineProps({
subject: { type: String, required: true },
columns: { type: Array, required: true },
rows: { type: Array, required: true },
intro: { type: String, default: '' },
to: { type: String, default: '' },
})
const toast = useToast()
const sending = ref(false)
async function emailReport() {
sending.value = true
try {
const payload = {
subject: props.subject,
columns: props.columns,
rows: props.rows,
intro: props.intro,
}
if (props.to) payload.to = props.to
const response = await reportsApi.email(payload)
const result = response.data?.data || {}
if (result.sent) {
toast.success('Report emailed.')
} else if (result.error) {
toast.error(`Report email failed: ${result.error}`)
} else {
toast.info(response.data?.message || 'Email is not configured.')
}
} catch (event) {
toast.error(apiError(event, 'Failed to email report'))
} finally {
sending.value = false
}
}
</script>

View File

@@ -87,6 +87,17 @@ export function useSystemSettings() {
qr_target_printer: '',
qr_target_usb: '',
usb_label_style: 'barcode',
qr_target_machine: '',
qr_target_computer: '',
qr_target_network_device: '',
qr_target_measuring_tool: '',
label_default_style: 'card',
label_default_codetype: 'qr',
label_default_encodes_machine: 'assetnumber',
label_default_encodes_computer: 'assetpage',
label_default_encodes_printer: 'assetpage',
label_default_encodes_network_device: 'assetpage',
label_default_encodes_measuring_tool: 'location',
// Email
smtp_enabled: false,
smtp_host: '',

View File

@@ -45,6 +45,13 @@ const routes = [
component: () => import('../views/Login.vue'),
meta: { guest: true }
},
// Forced/self-service password change (standalone, authenticated)
{
path: '/change-password',
name: 'change-password',
component: () => import('../views/ChangePassword.vue'),
meta: { requiresAuth: true }
},
// First-run setup wizard (standalone, admin-only)
{
path: '/setup',
@@ -70,6 +77,14 @@ const routes = [
name: 'print-machine-badge',
component: () => import('../views/print/MachineBadge.vue')
},
// Shared asset label/code generator for any asset type (public, like the
// other /print/* routes). assettype = machine|computer|printer|
// network_device|measuring_tool; id = the asset's plugin id.
{
path: '/print/asset-label/:assettype/:id',
name: 'print-asset-label',
component: () => import('../views/print/AssetLabel.vue')
},
{
path: '/print/printer-qr',
name: 'print-printer-qr-batch',
@@ -115,6 +130,13 @@ router.beforeEach(async (to, from, next) => {
return next('/')
}
// Forced password change: an admin-set temporary password must be replaced
// before the user reaches the rest of the app. Let them log out.
if (authStore.isAuthenticated && authStore.mustChangePassword
&& to.path !== '/change-password' && to.path !== '/login') {
return next('/change-password')
}
// Plugin gating: a disabled backend plugin's frontend routes are dead ends.
// The enabled list is fetched once and cached; fail-open on error so a blip
// cannot brick navigation. Works unauthenticated (endpoint is jwt-optional).

View File

@@ -13,6 +13,8 @@ export const useAuthStore = defineStore('auth', {
roles: (state) => state.user?.roles || [],
hasRole: (state) => (role) => state.user?.roles?.includes(role) || false,
isAdmin: (state) => state.user?.roles?.includes('admin') || false,
// True when an admin-set temporary password must be changed before use.
mustChangePassword: (state) => !!state.user?.mustchangepassword,
// Full name from the employee directory (falls back to username/SSO).
displayName: (state) => state.user?.directoryname || state.user?.username || '',
// Employee photo URL if the directory has one for this SSO. The directory
@@ -57,6 +59,15 @@ export const useAuthStore = defineStore('auth', {
localStorage.removeItem('user')
},
// Clear the forced-password-change flag after a successful change so the
// router guard stops steering the user to the change-password view.
clearMustChangePassword() {
if (this.user) {
this.user.mustchangepassword = false
localStorage.setItem('user', JSON.stringify(this.user))
}
},
async fetchUser() {
try {
const response = await authApi.me()

View File

@@ -45,6 +45,7 @@
<div class="username">{{ authStore.displayName }}</div>
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
</div>
<router-link to="/change-password" class="btn btn-secondary">Change password</router-link>
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
</template>
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>

View File

@@ -0,0 +1,89 @@
<template>
<div class="login-container">
<div class="login-box">
<img :src="siteLogo" alt="Site logo" class="login-logo" />
<h1>Change Password</h1>
<p v-if="forced" class="first-run-note">
Your account uses a temporary password. Set a new one to continue.
</p>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
<form @submit.prevent="handleSubmit">
<div v-if="!forced" class="form-group">
<label for="currentpassword">Current Password</label>
<input id="currentpassword" v-model="currentPassword" type="password"
class="form-control" required />
</div>
<div class="form-group">
<label for="newpassword">New Password</label>
<input id="newpassword" v-model="newPassword" type="password"
class="form-control" required minlength="8" autofocus />
</div>
<div class="form-group">
<label for="confirmpassword">Confirm New Password</label>
<input id="confirmpassword" v-model="confirmPassword" type="password"
class="form-control" required minlength="8" />
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Saving...' : 'Change password' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { authApi } from '../api'
import { getSiteLogo } from '../utils/siteSettings'
import { apiError } from '../utils/apiError'
const router = useRouter()
const authStore = useAuthStore()
const forced = computed(() => authStore.mustChangePassword)
const siteLogo = ref('/ge-aerospace-logo.svg')
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref('')
const success = ref('')
const loading = ref(false)
onMounted(() => {
getSiteLogo().then(logo => { siteLogo.value = logo })
})
async function handleSubmit() {
error.value = ''
success.value = ''
if (newPassword.value !== confirmPassword.value) {
error.value = 'New passwords do not match'
return
}
loading.value = true
try {
const payload = { new_password: newPassword.value }
if (!forced.value) payload.current_password = currentPassword.value
await authApi.changePassword(payload)
authStore.clearMustChangePassword()
success.value = 'Password changed.'
// Land in the app now that the forced-change flag is cleared.
setTimeout(() => router.push('/'), 600)
} catch (event) {
error.value = apiError(event, 'Failed to change password')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.first-run-note { color: var(--text-light); font-size: 0.9rem; margin-bottom: 1rem; }
</style>

View File

@@ -6,6 +6,9 @@
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Badge
</router-link>
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Label
</router-link>
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
Edit
</router-link>

View File

@@ -3,6 +3,9 @@
<div class="page-header">
<h2>Measuring Tool Details</h2>
<div class="header-actions">
<router-link :to="`/print/asset-label/measuring_tool/${tool?.measuringtool?.measuringtoolid}`" class="btn btn-secondary" v-if="tool" target="_blank">
Print Label
</router-link>
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
Edit
</router-link>

View File

@@ -174,6 +174,9 @@
<!-- Actions -->
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
Edit Device
</router-link>

View File

@@ -3,6 +3,7 @@
<div class="page-header">
<h2>Computer Details</h2>
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
</div>

View File

@@ -0,0 +1,276 @@
<template>
<div>
<!-- Controls (never printed) -->
<div class="no-print">
<div class="controls">
<h3>Print Asset Label</h3>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="!asset" class="error-msg">Asset not found</div>
<template v-else>
<div class="control-row">
<label>Style
<select v-model="style">
<option value="card">Card (badge)</option>
<option value="plain">Plain (code only)</option>
</select>
</label>
<label>Code type
<select v-model="codetype">
<option value="qr">QR code</option>
<option value="barcode">Barcode (CODE128)</option>
</select>
</label>
<label>Encodes
<select v-model="encodes">
<option value="assetpage">Asset page (link)</option>
<option value="assetnumber">Asset number</option>
<option value="serialnumber">Serial number</option>
<option v-if="hasLocation" value="location">Inspection location code</option>
<option value="custom">Custom target (settings template)</option>
</select>
</label>
</div>
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
This tool has no location; the label falls back to the asset page.
</p>
<button class="print-btn" @click="print">Print</button>
</template>
</div>
</div>
<!-- Printable area -->
<div v-if="asset" class="label-sheet">
<div class="asset-label" :class="style">
<template v-if="style === 'card'">
<div class="label-title">{{ cardTitle }}</div>
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
<div class="label-fields">
<div v-for="field in identityFields" :key="field.label" class="label-field">
<span class="field-label">{{ field.label }}</span>
<span class="field-value">{{ field.value }}</span>
</div>
</div>
</template>
<div class="code-area">
<template v-if="codeText">
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
<div class="code-caption">{{ caption }}</div>
</template>
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import JsBarcode from 'jsbarcode'
import { renderQrDataUrl } from './qrLogo'
import { getSetting } from '@/utils/siteSettings'
import {
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
effectiveEncodes as effEncodes, resolveCodeText as resolveText, captionFor,
} from './assetLabel'
const route = useRoute()
const assettype = route.params.assettype
const config = TYPE_CONFIG[assettype] || null
const loading = ref(true)
const asset = ref(null)
const barcodeEl = ref(null)
const qrImage = ref('')
// The resolved string the code encodes; empty when the chosen field has no
// value (e.g. serial number on an asset with none) so the UI can explain it.
const codeText = ref('')
const style = ref('card')
const codetype = ref('qr')
const encodes = ref('assetpage')
const hasLocation = computed(() => hasLocationType(assettype))
const extension = computed(() => (asset.value && config) ? asset.value[config.extkey] : null)
const cardTitle = computed(() => {
if (!asset.value) return ''
const ext = extension.value || {}
return ext.modelname
|| ext.measuringtooltypename
|| asset.value.assettypename
|| (config ? config.label : 'Asset')
})
const imageUrl = computed(() => {
const ext = extension.value
return (ext && ext.imageurl) ? ext.imageurl : null
})
// Small identity table shown on the card.
const identityFields = computed(() => {
if (!asset.value) return []
const rows = []
if (asset.value.assetnumber) rows.push({ label: 'Asset #', value: asset.value.assetnumber })
if (asset.value.serialnumber) rows.push({ label: 'Serial', value: asset.value.serialnumber })
if (asset.value.name) rows.push({ label: 'Name', value: asset.value.name })
if (asset.value.locationname) rows.push({ label: 'Location', value: asset.value.locationname })
return rows
})
// Human caption printed under the code.
const caption = computed(() => captionFor(asset.value, encodes.value))
// Human phrase for the current encode mode, used in the "nothing to encode"
// message.
const ENCODE_LABELS = {
assetnumber: 'asset number', serialnumber: 'serial number',
location: 'inspection location', assetpage: 'page link', custom: 'target',
}
const encodeLabel = computed(() =>
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
async function renderCode() {
const text = await resolveText(assettype, asset.value, encodes.value)
codeText.value = text
if (!text) { qrImage.value = ''; return }
if (codetype.value === 'qr') {
qrImage.value = await renderQrDataUrl(text)
} else {
await nextTick()
if (!barcodeEl.value) return
try {
JsBarcode(barcodeEl.value, text, {
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
})
} catch (err) {
console.error('Barcode error:', err)
}
}
}
onMounted(async () => {
if (!config) { loading.value = false; return }
style.value = (await getSetting('label_default_style', 'card')) === 'plain' ? 'plain' : 'card'
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
encodes.value = await resolveDefaultEncodes(assettype)
try {
const response = await config.api.get(route.params.id)
asset.value = response.data.data
} catch (err) {
console.error('Error loading asset:', err)
} finally {
loading.value = false
await nextTick()
renderCode()
}
})
watch([style, codetype, encodes], renderCode)
function print() {
window.print()
}
</script>
<style scoped>
@page { size: 2.13in 3.38in; margin: 0; }
.no-print { padding: 20px; }
.controls {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
max-width: 40rem;
}
.controls h3 { margin-top: 0; }
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
.control-row select { padding: 6px; font-size: 0.875rem; }
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
}
.print-btn:hover { background: var(--primary-dark); }
.loading-msg, .error-msg { padding: 1rem 0; color: var(--text-light); }
.label-sheet { display: flex; justify-content: center; padding: 20px 0; }
.asset-label {
width: 2.13in;
min-height: 3.38in;
background: white;
color: #000;
border: 1px solid #ccc;
box-sizing: border-box;
padding: 0.15in;
display: flex;
flex-direction: column;
align-items: center;
}
.asset-label.plain { justify-content: center; min-height: 2in; }
.label-title {
font-size: 12pt;
font-weight: bold;
text-align: center;
margin-bottom: 0.08in;
}
.label-image {
max-width: 1.6in;
max-height: 1.2in;
object-fit: contain;
margin-bottom: 0.08in;
}
.label-fields { width: 100%; margin-bottom: 0.08in; }
.label-field {
display: flex;
justify-content: space-between;
gap: 6px;
font-size: 8pt;
line-height: 1.4;
}
.field-label { color: #555; }
.field-value { font-weight: bold; text-align: right; word-break: break-all; }
.code-area {
margin-top: auto;
text-align: center;
width: 100%;
}
.code-qr { width: 1.5in; height: 1.5in; }
.code-barcode { width: 1.8in; height: 0.9in; }
.code-caption {
font-size: 12pt;
font-weight: bold;
font-family: monospace;
margin-top: 0.02in;
}
.code-missing {
font-size: 9pt;
color: #999;
padding: 0.3in 0.1in;
}
@media print {
.no-print { display: none !important; }
.label-sheet { padding: 0; }
.asset-label { border: none; }
body, .asset-label, .code-qr, .code-barcode {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
</style>

View File

@@ -0,0 +1,99 @@
// Shared wiring for the asset label pages (single AssetLabel.vue and batch
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
// encode" resolution so both surfaces behave identically.
import {
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
} from '../../api'
import { fillUrlTemplate } from '@/utils/qrTarget'
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
// Per asset-type: which api loads it, its detail/list routes, the extension key
// nested in the merged payload, and its qr_target_<type> settings key.
export const TYPE_CONFIG = {
machine: {
api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine',
label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines',
},
computer: {
api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer',
label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs',
},
printer: {
api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer',
label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers',
},
network_device: {
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
},
measuring_tool: {
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
},
}
// Hardcoded fallback default per type when the site setting is unset. Machines
// encode their machine number, measuring tools their inspection location.
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
export function hasLocationType(assettype) {
return assettype === 'measuring_tool'
}
// The default encode mode for a type: the label_default_encodes_<type> site
// setting when valid, else the hardcoded fallback.
export async function resolveDefaultEncodes(assettype) {
const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom']
if (hasLocationType(assettype)) valid.push('location')
const seeded = await getSetting(`label_default_encodes_${assettype}`, '')
return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage')
}
// 'location' degrades to the asset page when the asset has no location code.
export function effectiveEncodes(encodes, asset) {
if (encodes === 'location' && !asset?.locationcode) return 'assetpage'
return encodes
}
function tokensFor(asset) {
return {
assetid: asset.assetid || '',
assetnumber: asset.assetnumber || '',
serialnumber: asset.serialnumber || '',
name: asset.name || '',
pluginid: asset.pluginid || '',
locationcode: asset.locationcode || '',
locationname: asset.locationname || '',
}
}
// The string a label's code encodes for one asset. Empty when the chosen field
// has no value (e.g. serial number on an asset with none).
export async function resolveCodeText(assettype, asset, encodes) {
const config = TYPE_CONFIG[assettype]
if (!config || !asset) return ''
const detailId = asset.pluginid
const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}`
switch (effectiveEncodes(encodes, asset)) {
case 'assetnumber': return asset.assetnumber || ''
case 'serialnumber': return asset.serialnumber || ''
case 'location': return asset.locationcode || ''
case 'custom': {
const template = (await getSetting(config.targetKey, '')).trim()
return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl
}
case 'assetpage':
default: return assetPageUrl
}
}
// Human caption printed under the code.
export function captionFor(asset, encodes) {
if (!asset) return ''
switch (effectiveEncodes(encodes, asset)) {
case 'location': return asset.locationcode || ''
case 'serialnumber': return asset.serialnumber || ''
case 'assetnumber':
default: return asset.assetnumber || ''
}
}

View File

@@ -6,6 +6,9 @@
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
Print QR
</router-link>
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div>

View File

@@ -4,6 +4,8 @@
<h1>Toner Report</h1>
<div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
@@ -99,6 +101,17 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import EmailReportButton from '../../components/EmailReportButton.vue'
const emailColumns = [
{ key: 'printer', label: 'Printer' },
{ key: 'assetnumber', label: 'Asset #' },
{ key: 'location', label: 'Location' },
{ key: 'ipaddress', label: 'IP Address' },
{ key: 'supply', label: 'Supply' },
{ key: 'level', label: 'Level' },
{ key: 'status', label: 'Status' },
]
const loading = ref(true)
const error = ref(null)
@@ -119,6 +132,25 @@ const filteredPrinters = computed(() => {
)
})
// One row per supply, honoring the active filter, for the emailed table.
const emailRows = computed(() => {
const rows = []
for (const printer of filteredPrinters.value) {
for (const supply of printer.supplies || []) {
rows.push({
printer: printer.printername || '',
assetnumber: printer.assetnumber || '',
location: printer.location || '',
ipaddress: printer.ipaddress || '',
supply: supply.name || '',
level: supply.level + '%',
status: supply.status || '',
})
}
}
return rows
})
function exportCSV() {
// one row per supply, honoring the active filter
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`

View File

@@ -4,6 +4,8 @@
<h1>Warranty Report</h1>
<div class="header-actions">
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading" subject="Warranty Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
@@ -53,13 +55,39 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { warrantyApi } from '../../api'
import EmailReportButton from '../../components/EmailReportButton.vue'
const loading = ref(true)
const counts = ref({})
const buckets = ref({})
const emailColumns = [
{ key: 'bucket', label: 'Status' },
{ key: 'vendor', label: 'Vendor' },
{ key: 'servicelevel', label: 'Service Level' },
{ key: 'enddate', label: 'Ends' },
{ key: 'assets', label: 'Covers' },
]
// Flatten the buckets into one row per warranty for the emailed table.
const emailRows = computed(() => {
const rows = []
for (const b of bucketOrder) {
for (const w of buckets.value[b.key] || []) {
rows.push({
bucket: b.label,
vendor: w.vendor || '',
servicelevel: w.servicelevel || '',
enddate: w.enddate || '',
assets: (w.assets || []).map(a => a.assetnumber).join(', '),
})
}
}
return rows
})
const bucketOrder = [
{ key: 'expired', label: 'Expired', color: '#F44336' },
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },

View File

@@ -162,6 +162,7 @@
import { onMounted, ref, computed } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
import { apiError } from '../../utils/apiError'
import { settingsApi } from '../../api'
const {
settings, saving, error, success,
@@ -189,18 +190,25 @@ const canTestEmail = computed(() => {
})
async function testEmail() {
// TODO: Implement test email endpoint
testingEmail.value = true
error.value = ''
success.value = ''
try {
// await settingsApi.testEmail()
success.value = 'Test email feature coming soon'
// Send to the configured Alert Recipients (the endpoint splits the list).
const response = await settingsApi.testEmail(settings.alert_recipients)
const result = response.data?.data || {}
if (result.sent) {
success.value = `Test email sent to ${settings.alert_recipients}`
} else {
error.value = result.error
? `Test email failed: ${result.error}`
: (response.data?.message || 'Email is not configured')
}
} catch (e) {
error.value = apiError(e, 'Failed to send test email')
} finally {
testingEmail.value = false
setTimeout(() => { success.value = '' }, 3000)
setTimeout(() => { success.value = '' }, 4000)
}
}

View File

@@ -40,6 +40,114 @@
</label>
</div>
<div class="setting-row">
<label>
<span>Machine label target</span>
<input
type="text"
v-model="settings.qr_target_machine"
placeholder="(blank = machine page)"
@blur="saveSetting('qr_target_machine', settings.qr_target_machine)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Computer label target</span>
<input
type="text"
v-model="settings.qr_target_computer"
placeholder="(blank = computer page)"
@blur="saveSetting('qr_target_computer', settings.qr_target_computer)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Network device label target</span>
<input
type="text"
v-model="settings.qr_target_network_device"
placeholder="(blank = network device page)"
@blur="saveSetting('qr_target_network_device', settings.qr_target_network_device)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Measuring tool label target</span>
<input
type="text"
v-model="settings.qr_target_measuring_tool"
placeholder="(blank = measuring tool page)"
@blur="saveSetting('qr_target_measuring_tool', settings.qr_target_measuring_tool)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Default label style</span>
<select
v-model="settings.label_default_style"
@change="saveSetting('label_default_style', settings.label_default_style)"
:disabled="saving"
>
<option value="card">Card (badge with image and identity)</option>
<option value="plain">Plain (just the code and a caption)</option>
</select>
<small class="input-hint">House style used when an asset label first opens.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Default label code type</span>
<select
v-model="settings.label_default_codetype"
@change="saveSetting('label_default_codetype', settings.label_default_codetype)"
:disabled="saving"
>
<option value="qr">QR code</option>
<option value="barcode">Barcode (CODE128)</option>
</select>
<small class="input-hint">Code type used when an asset label first opens.</small>
</label>
</div>
<p class="setting-description">
What each asset type's label encodes by default (still overridable on
the label page itself).
</p>
<div class="setting-row" v-for="row in encodesRows" :key="row.key">
<label>
<span>{{ row.label }} label content</span>
<select
v-model="settings[row.key]"
@change="saveSetting(row.key, settings[row.key])"
:disabled="saving"
>
<option value="assetpage">Link to the asset page</option>
<option value="assetnumber">Asset / machine number</option>
<option value="serialnumber">Serial number</option>
<option v-if="row.hasLocation" value="location">Inspection location code</option>
<option value="custom">Custom target template</option>
</select>
</label>
</div>
<div class="setting-row">
<label>
<span>USB label style</span>
@@ -71,5 +179,14 @@ const {
loadSettings, saveSetting,
} = useSystemSettings()
// Per-asset-type "what does the code encode" defaults.
const encodesRows = [
{ key: 'label_default_encodes_machine', label: 'Machine' },
{ key: 'label_default_encodes_computer', label: 'Computer' },
{ key: 'label_default_encodes_printer', label: 'Printer' },
{ key: 'label_default_encodes_network_device', label: 'Network device' },
{ key: 'label_default_encodes_measuring_tool', label: 'Measuring tool', hasLocation: true },
]
onMounted(loadSettings)
</script>

View File

@@ -0,0 +1,50 @@
"""User forced-password-change flag (users.mustchangepassword)
Adds a boolean users.mustchangepassword column (default false). An admin who
creates a user sets it true so the user is forced through a password change on
first login; changing the password clears it.
Idempotent guard so it is safe on a partially-migrated box; real downgrade.
Revision ID: 7d23_user_mustchangepassword
Revises: 7d22_apitokens_scopes
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = '7d23_user_mustchangepassword'
down_revision = '7d22_apitokens_scopes'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'users' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('users')}
if 'mustchangepassword' in columns:
return
op.add_column(
'users',
sa.Column('mustchangepassword', sa.Boolean(),
nullable=False, server_default=sa.false()))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'users' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('users')}
if 'mustchangepassword' not in columns:
return
op.drop_column('users', 'mustchangepassword')

View File

@@ -11,7 +11,7 @@ from flask_jwt_extended import (
get_jwt_identity,
current_user
)
from werkzeug.security import check_password_hash
from werkzeug.security import check_password_hash, generate_password_hash
from shopdb.extensions import db, cache
from shopdb.core.models import User
@@ -151,7 +151,8 @@ def login():
'email': user.email,
'firstname': user.firstname,
'lastname': user.lastname,
'roles': [r.rolename for r in user.roles]
'roles': [r.rolename for r in user.roles],
'mustchangepassword': bool(user.mustchangepassword)
}
})
@@ -196,10 +197,59 @@ def get_current_user():
'firstname': current_user.firstname,
'lastname': current_user.lastname,
'roles': [r.rolename for r in current_user.roles],
'permissions': current_user.getpermissions()
'permissions': current_user.getpermissions(),
'mustchangepassword': bool(current_user.mustchangepassword)
})
@auth_bp.route('/change-password', methods=['POST'])
@jwt_required()
def change_password():
"""Change the authenticated user's own password.
Request:
{ "current_password": "string", "new_password": "string" }
current_password is required for a normal self-service change. When the
account is flagged mustchangepassword (an admin set a temporary password),
the forced-change case accepts new_password alone. On success the flag is
cleared and any lockout/failed-login state is reset.
"""
data = request.get_json() or {}
new_password = data.get('new_password')
current_password = data.get('current_password')
if not new_password:
return error_response(
ErrorCodes.VALIDATION_ERROR, 'new_password is required')
if len(new_password) < 8:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'New password must be at least 8 characters')
user = current_user
# A normal change must prove knowledge of the current password. The forced
# first-login case (admin-set temp password) may skip it.
if not user.mustchangepassword:
if not current_password:
return error_response(
ErrorCodes.VALIDATION_ERROR, 'current_password is required')
if not check_password_hash(user.passwordhash, current_password):
return error_response(
ErrorCodes.UNAUTHORIZED,
'Current password is incorrect', http_code=401)
user.passwordhash = generate_password_hash(new_password)
user.mustchangepassword = False
user.failedlogins = 0
user.lockeduntil = None
db.session.commit()
return success_response(
{'mustchangepassword': False}, message='Password changed')
@auth_bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():

View File

@@ -12,6 +12,7 @@ from shopdb.core.models import (
Application
)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
reports_bp = Blueprint('reports', __name__)
@@ -513,6 +514,55 @@ def pc_relationships():
})
@reports_bp.route('/email', methods=['POST'])
@jwt_required()
@require_permission('reports.export')
def email_report():
"""Email a report's data as an HTML table (on-demand delivery).
Request:
{
"subject": "Warranty Report",
"columns": [{"key": "vendor", "label": "Vendor"}, ...],
"rows": [{"vendor": "...", ...}, ...],
"intro": "optional lead paragraph",
"to": "addr@example.com" // optional; defaults to alert_recipients
}
Recipients default to the site's Alert Recipients when `to` is omitted, so
this doubles as the alert delivery path. There is no scheduler in this app:
sending is on-demand. To automate it, point an external cron job at this
endpoint using an API token (PAT) scoped to reports.export.
"""
from shopdb.utils.mailer import get_smtp_config, render_table_email, try_send
data = request.get_json() or {}
subject = data.get('subject') or 'ShopDB report'
columns = data.get('columns') or []
rows = data.get('rows') or []
config = get_smtp_config()
recipient = data.get('to') or config['alert_recipients']
if not config['enabled'] or not config['host']:
return success_response(
{'sent': False, 'reason': 'notconfigured'},
message='Email is not configured (SMTP disabled or host unset).')
if not recipient:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No recipient. Provide "to" or set Alert Recipients.')
html, text = render_table_email(
subject, columns, rows, intro=data.get('intro'))
ok, error = try_send(recipient, subject, html, text=text)
if ok:
return success_response({'sent': True}, message='Report emailed.')
return success_response(
{'sent': False, 'error': error},
message='Report email failed: ' + (error or 'unknown error'))
@reports_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_reports():

View File

@@ -264,6 +264,44 @@ def update_setting(key: str):
return success_response(_serialize_setting(setting), message='Setting updated')
@settings_bp.route('/test-email', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def test_email():
"""Send a test email to verify SMTP configuration.
Request: { "to": "addr@example.com" } (falls back to alert_recipients)
Returns a 200 with a `sent` flag either way. When SMTP is not configured
the response explains that gracefully; when a real send fails the SMTP error
is surfaced with any credential scrubbed out.
"""
from shopdb.utils.mailer import get_smtp_config, render_email, try_send
data = request.get_json() or {}
config = get_smtp_config()
recipient = data.get('to') or config['alert_recipients']
if not config['enabled'] or not config['host']:
return success_response(
{'sent': False, 'reason': 'notconfigured'},
message='Email is not configured (SMTP disabled or host unset).')
if not recipient:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No recipient. Provide "to" or set Alert Recipients.')
html, text = render_email(
'ShopDB test email',
'<p>This is a test message confirming your SMTP settings work.</p>')
ok, error = try_send(recipient, 'ShopDB test email', html, text=text)
if ok:
return success_response({'sent': True}, message='Test email sent.')
return success_response(
{'sent': False, 'error': error},
message='Test email failed: ' + (error or 'unknown error'))
@settings_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
@@ -530,6 +568,73 @@ def build_default_settings():
'category': 'printing',
'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)."
},
{
'key': 'qr_target_machine',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for machine labels. Blank = link to the machine page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_computer',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for computer labels. Blank = link to the computer page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_network_device',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
},
{
'key': 'qr_target_measuring_tool',
'value': '',
'valuetype': 'string',
'category': 'printing',
'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.'
},
{
'key': 'label_default_style',
'value': 'card',
'valuetype': 'string',
'category': 'printing',
'description': "Default asset-label layout: 'card' (badge with image and identity) or 'plain' (just the code and a caption)."
},
{
'key': 'label_default_codetype',
'value': 'qr',
'valuetype': 'string',
'category': 'printing',
'description': "Default asset-label code type: 'qr' (QR code) or 'barcode' (CODE128)."
},
]
# Per-asset-type default for what a label's code encodes. Machines default
# to their machine number (assetnumber), measuring tools to their inspection
# location code, everything else to a link to the asset page. Values:
# assetpage | assetnumber | serialnumber | location | custom.
labelencodesdefaults = {
'machine': 'assetnumber',
'computer': 'assetpage',
'printer': 'assetpage',
'network_device': 'assetpage',
'measuring_tool': 'location',
}
printingdefaults += [
{
'key': f'label_default_encodes_{assettype}',
'value': value,
'valuetype': 'string',
'category': 'printing',
'description': f'What a {assettype} label encodes by default: assetpage, '
'assetnumber, serialnumber'
+ (', location' if assettype == 'measuring_tool' else '')
+ ', or custom.',
}
for assettype, value in labelencodesdefaults.items()
]
# Collector pc-type -> ComputerType mapping is computers-plugin domain;

View File

@@ -60,13 +60,18 @@ def create_user():
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)
isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange)
)
# Assign roles
@@ -82,7 +87,47 @@ def create_user():
db.session.commit()
return success_response(user_to_dict(user), message='User created', http_code=201)
# 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'])
@@ -335,6 +380,7 @@ def user_to_dict(user: User) -> dict:
'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],

View File

@@ -7,7 +7,7 @@ from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
from .dashboarddefault import DashboardDefault
from .location import Location, LocationType
from .location import Location, LocationType, derive_locationcode
from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
from .communication import Communication, CommunicationType
@@ -37,6 +37,7 @@ __all__ = [
'DashboardDefault',
'Location',
'LocationType',
'derive_locationcode',
'OperatingSystem',
# Relationships
'AssetRelationship',

View File

@@ -251,6 +251,12 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
if result.get('mapy') is None:
result['mapy'] = inherited['mapy']
# Operation/short code of the resolved location (own or inherited).
# Derived from the location name's leading token; labels can encode a
# tool's inspection operation instead of the tool. None when unplaced.
from .location import derive_locationcode
result['locationcode'] = derive_locationcode(result.get('locationname'))
# Include extension data if requested
if include_type_data:
ext_data = self._get_extension_data()

View File

@@ -4,6 +4,21 @@ from shopdb.extensions import db
from .base import BaseModel
def derive_locationcode(locationname):
"""Operation/short code for a location = leading token of its name.
Locations have no dedicated code column, so the operation code is the
leading whitespace-delimited token of the location name. Example:
'0615 Blisk Inspection' -> '0615'. Blank/None name -> None. Used by
printed labels that encode a tool's inspection operation rather than the
tool itself.
"""
if not locationname:
return None
parts = str(locationname).strip().split()
return parts[0] if parts else None
class LocationType(BaseModel):
"""Location classification (ADR-001 shared reference data).
@@ -60,10 +75,16 @@ class Location(BaseModel):
locationtype = db.relationship('LocationType')
parent = db.relationship('Location', remote_side=[locationid])
@property
def locationcode(self):
"""Derived operation/short code (leading token of the name)."""
return derive_locationcode(self.locationname)
def to_dict(self):
data = super().to_dict()
data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None
data['parentlocationname'] = self.parent.locationname if self.parent else None
data['locationcode'] = self.locationcode
return data
def __repr__(self):

View File

@@ -198,6 +198,9 @@ class User(BaseModel):
lastlogindate = db.Column(db.DateTime)
failedlogins = db.Column(db.Integer, default=0)
lockeduntil = db.Column(db.DateTime)
# Forced password change: set true when an admin creates the account, so the
# user is steered through a password change before landing in the app.
mustchangepassword = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
roles = db.relationship(

259
shopdb/utils/mailer.py Normal file
View File

@@ -0,0 +1,259 @@
"""Email sending service (stdlib smtplib/ssl/email only).
Reads SMTP configuration settings-first (via the cached settings map) with an
environment-variable fallback when any SMTP_* env var is present. When email is
disabled or the host is unset the sender is a graceful no-op that logs a warning
and returns False, so an unconfigured site never crashes on a send attempt.
Public helpers:
send_email(to, subject, html, text=None) -> bool
try_send(to, subject, html, text=None) -> (bool, error_or_None)
send_alert(subject, html, text=None) -> bool
render_email(title, body_html, intro=None) -> (html, text)
render_table_email(title, columns, rows, intro=None) -> (html, text)
The SMTP password is never logged.
"""
import os
import re
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from flask import current_app, has_app_context
# Env var names that back each SMTP setting when settings are blank.
_ENV_MAP = {
'smtp_host': 'SMTP_HOST',
'smtp_port': 'SMTP_PORT',
'smtp_username': 'SMTP_USERNAME',
'smtp_password': 'SMTP_PASSWORD',
'smtp_from_address': 'SMTP_FROM_ADDRESS',
'smtp_from_name': 'SMTP_FROM_NAME',
'alert_recipients': 'SMTP_ALERT_RECIPIENTS',
}
# Connect/send timeout in seconds. Keeps a wedged relay from hanging a request.
_SMTP_TIMEOUT = 10
def _log():
"""App logger when in an app context, else a module logger."""
if has_app_context():
return current_app.logger
import logging
return logging.getLogger('shopdb.mailer')
def _env_active():
"""True when the deployment supplies SMTP_* env overrides."""
return any(k.startswith('SMTP_') for k in os.environ)
def get_smtp_config():
"""Resolve the SMTP config settings-first with env fallback.
Returns a dict with typed fields. `enabled` is False when the site has not
turned email on; callers should treat that as a no-op signal.
"""
settings = {}
if has_app_context():
# Local import avoids a circular import at module load.
from shopdb.core.api.settings import get_cached_settings
try:
settings = get_cached_settings() or {}
except Exception:
settings = {}
env_active = _env_active()
def pick(key, default=''):
val = settings.get(key)
if (val is None or val == '') and env_active:
val = os.environ.get(_ENV_MAP.get(key, ''), default)
return default if val is None else val
enabled = bool(settings.get('smtp_enabled'))
if not enabled and env_active:
enabled = os.environ.get('SMTP_ENABLED', '').lower() in ('true', '1', 'yes')
use_tls = settings.get('smtp_use_tls')
if use_tls is None:
if env_active:
use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() in ('true', '1', 'yes')
else:
use_tls = True
try:
port = int(pick('smtp_port', 587) or 587)
except (ValueError, TypeError):
port = 587
return {
'enabled': enabled,
'host': pick('smtp_host'),
'port': port,
'username': pick('smtp_username'),
'password': pick('smtp_password'),
'use_tls': bool(use_tls),
'from_address': pick('smtp_from_address'),
'from_name': pick('smtp_from_name') or 'ShopDB',
'alert_recipients': pick('alert_recipients'),
}
def _normalize_recipients(to):
"""Coerce a recipient spec (string, comma/semicolon list, or iterable) to a
clean list of addresses."""
if not to:
return []
if isinstance(to, str):
parts = re.split(r'[,;]', to)
else:
parts = list(to)
return [p.strip() for p in parts if p and p.strip()]
def try_send(to, subject, html, text=None):
"""Send an email. Returns (ok, error).
ok is False with error=None when email is not configured (a graceful
no-op). ok is False with an error string when a real send failed. The SMTP
password is never included in the error.
"""
config = get_smtp_config()
recipients = _normalize_recipients(to)
if not config['enabled'] or not config['host']:
_log().warning('Email not sent: SMTP is disabled or host is unset.')
return False, None
if not recipients:
_log().warning('Email not sent: no recipients.')
return False, 'No recipients specified'
if not config['from_address']:
_log().warning('Email not sent: from address is unset.')
return False, 'From address is not configured'
message = MIMEMultipart('alternative')
message['Subject'] = subject
message['From'] = formataddr((config['from_name'], config['from_address']))
message['To'] = ', '.join(recipients)
# Plaintext first so alternative-aware clients prefer the HTML part.
message.attach(MIMEText(text or _html_to_text(html), 'plain', 'utf-8'))
message.attach(MIMEText(html, 'html', 'utf-8'))
try:
context = ssl.create_default_context()
if config['port'] == 465:
server = smtplib.SMTP_SSL(
config['host'], config['port'],
timeout=_SMTP_TIMEOUT, context=context)
else:
server = smtplib.SMTP(
config['host'], config['port'], timeout=_SMTP_TIMEOUT)
with server:
if config['port'] != 465 and config['use_tls']:
server.starttls(context=context)
if config['username']:
server.login(config['username'], config['password'])
server.sendmail(config['from_address'], recipients, message.as_string())
_log().info('Email sent to %d recipient(s): %s', len(recipients), subject)
return True, None
except Exception as exception:
# Never let the password reach the log or the caller.
error = _scrub(str(exception), config['password'])
_log().error('Email send failed: %s', error)
return False, error
def send_email(to, subject, html, text=None):
"""Send an email. Returns True on success, False otherwise (no-op safe)."""
ok, _error = try_send(to, subject, html, text=text)
return ok
def send_alert(subject, html, text=None):
"""Send an alert to the site's configured alert_recipients. Returns False
when email is off or no alert recipients are configured."""
config = get_smtp_config()
recipients = _normalize_recipients(config['alert_recipients'])
if not recipients:
_log().warning('Alert not sent: no alert_recipients configured.')
return False
return send_email(recipients, subject, html, text=text)
def _scrub(value, secret):
"""Remove a secret substring from a string (defensive log hygiene)."""
if secret and secret in value:
return value.replace(secret, '***')
return value
def _html_to_text(html):
"""Very small HTML-to-text fallback for the plaintext alternative."""
text = re.sub(r'(?i)<br\s*/?>', '\n', html)
text = re.sub(r'(?i)</(p|tr|div|h[1-6]|li)>', '\n', text)
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
def _escape(value):
"""HTML-escape a cell value."""
return (str('' if value is None else value)
.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;'))
def render_email(title, body_html, intro=None):
"""Wrap body HTML in a simple branded shell. Returns (html, text)."""
intro_html = f'<p style="margin:0 0 16px;color:#444;">{_escape(intro)}</p>' if intro else ''
html = (
'<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;'
'margin:0 auto;color:#222;">'
f'<h2 style="color:#1a1a1a;margin:0 0 12px;">{_escape(title)}</h2>'
f'{intro_html}{body_html}'
'<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">'
'<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>'
'</div>'
)
return html, _html_to_text(html)
def render_table_email(title, columns, rows, intro=None):
"""Render tabular report data as an HTML table email. Returns (html, text).
columns: list of {'key','label'} dicts or of plain strings.
rows: list of dicts keyed by the column keys.
"""
normalized = []
for column in columns or []:
if isinstance(column, dict):
normalized.append((column.get('key'), column.get('label', column.get('key'))))
else:
normalized.append((column, column))
header_cells = ''.join(
f'<th style="text-align:left;padding:8px 10px;border-bottom:2px solid #ccc;'
f'background:#f4f4f4;">{_escape(label)}</th>'
for _key, label in normalized)
body_rows = []
for row in rows or []:
cells = ''.join(
f'<td style="padding:8px 10px;border-bottom:1px solid #eee;">'
f'{_escape(row.get(key) if isinstance(row, dict) else row)}</td>'
for key, _label in normalized)
body_rows.append(f'<tr>{cells}</tr>')
table = (
'<table style="border-collapse:collapse;width:100%;font-size:14px;">'
f'<thead><tr>{header_cells}</tr></thead>'
f'<tbody>{"".join(body_rows) or "<tr><td>No data</td></tr>"}</tbody>'
'</table>'
)
count_line = f'<p style="color:#666;font-size:13px;">{len(rows or [])} row(s).</p>'
return render_email(title, table + count_line, intro=intro)

View File

@@ -0,0 +1,270 @@
"""Tests for the email service, forced password change, welcome email, and
on-demand report/alert delivery.
smtplib is always patched so tests never touch the network.
"""
import os
from unittest.mock import patch, MagicMock
import pytest
from werkzeug.security import generate_password_hash
@pytest.fixture(autouse=True)
def _strip_smtp_env(monkeypatch):
"""Remove any SMTP_* env so env-fallback cannot enable email unexpectedly."""
for key in list(os.environ):
if key.startswith('SMTP_'):
monkeypatch.delenv(key, raising=False)
def _enable_smtp(db):
"""Seed the minimum settings to make SMTP 'configured'."""
from shopdb.core.models import Setting
from shopdb.core.api.settings import invalidate_settings_cache
rows = {
'smtp_enabled': ('true', 'boolean'),
'smtp_host': ('smtp.test.local', 'string'),
'smtp_port': ('587', 'integer'),
'smtp_use_tls': ('true', 'boolean'),
'smtp_from_address': ('noreply@test.local', 'string'),
'smtp_from_name': ('ShopDB', 'string'),
'alert_recipients': ('ops@test.local', 'string'),
}
for key, (value, valuetype) in rows.items():
db.session.add(Setting(key=key, value=value, valuetype=valuetype,
category='email'))
db.session.commit()
invalidate_settings_cache()
def _make_user(db, username, password='temppass1', mustchange=False,
failedlogins=0):
from shopdb.core.models import User
user = User(
username=username,
email=f'{username}@test.local',
passwordhash=generate_password_hash(password),
mustchangepassword=mustchange,
failedlogins=failedlogins,
)
db.session.add(user)
db.session.commit()
return user
def _login(client, username, password):
response = client.post('/api/auth/login',
json={'username': username, 'password': password})
return response
# ---------------------------------------------------------------------------
# Mailer no-op behavior
# ---------------------------------------------------------------------------
def test_mailer_noop_when_disabled(app, db):
"""send_email is a no-op returning False when SMTP is disabled; no connect."""
from shopdb.utils import mailer
with app.app_context():
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
result = mailer.send_email('a@test.local', 'Hi', '<p>Hi</p>')
assert result is False
smtp.assert_not_called()
def test_mailer_sends_when_configured(app, db):
"""send_email connects and sends when SMTP is configured (patched)."""
from shopdb.utils import mailer
with app.app_context():
_enable_smtp(db)
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
instance = MagicMock()
smtp.return_value = instance
result = mailer.send_email('a@test.local', 'Hi', '<p>Hi</p>')
assert result is True
instance.sendmail.assert_called_once()
def test_mailer_never_logs_password(app, db):
"""A send failure carrying the password is scrubbed in the returned error."""
from shopdb.utils import mailer
with app.app_context():
from shopdb.core.models import Setting
from shopdb.core.api.settings import invalidate_settings_cache
_enable_smtp(db)
db.session.add(Setting(key='smtp_password', value='s3cret',
valuetype='string', category='email'))
db.session.commit()
invalidate_settings_cache()
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
instance = MagicMock()
instance.sendmail.side_effect = RuntimeError('auth failed s3cret')
smtp.return_value = instance
ok, error = mailer.try_send('a@test.local', 'Hi', '<p>Hi</p>')
assert ok is False
assert 's3cret' not in error
# ---------------------------------------------------------------------------
# Test-email endpoint
# ---------------------------------------------------------------------------
def test_test_email_requires_admin(client, db, member_headers):
response = client.post('/api/settings/test-email',
json={'to': 'x@test.local'}, headers=member_headers)
assert response.status_code == 403
def test_test_email_graceful_when_unconfigured(client, db, auth_headers):
response = client.post('/api/settings/test-email',
json={'to': 'x@test.local'}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['sent'] is False
def test_test_email_sends_when_configured(client, db, auth_headers):
_enable_smtp(db)
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
smtp.return_value = MagicMock()
response = client.post('/api/settings/test-email',
json={'to': 'x@test.local'}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['sent'] is True
# ---------------------------------------------------------------------------
# Forced password change
# ---------------------------------------------------------------------------
def test_login_surfaces_mustchangepassword(client, db):
_make_user(db, 'forceduser', password='temppass1', mustchange=True)
response = _login(client, 'forceduser', 'temppass1')
assert response.status_code == 200
assert response.get_json()['data']['user']['mustchangepassword'] is True
def test_forced_change_clears_flag_and_lockout(client, db):
user = _make_user(db, 'forceduser', password='temppass1', mustchange=True,
failedlogins=3)
token = _login(client, 'forceduser', 'temppass1').get_json()['data']['access_token']
headers = {'Authorization': f'Bearer {token}'}
# Forced case: new_password only, no current required.
response = client.post('/api/auth/change-password',
json={'new_password': 'brandnew123'}, headers=headers)
assert response.status_code == 200
from shopdb.core.models import User
refreshed = db.session.get(User, user.userid)
assert refreshed.mustchangepassword is False
assert refreshed.failedlogins == 0
assert refreshed.lockeduntil is None
# New password works.
assert _login(client, 'forceduser', 'brandnew123').status_code == 200
def test_selfservice_change_rejects_wrong_current(client, db):
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
headers = {'Authorization': f'Bearer {token}'}
response = client.post('/api/auth/change-password',
json={'current_password': 'wrongpass',
'new_password': 'brandnew123'}, headers=headers)
assert response.status_code == 401
def test_selfservice_change_succeeds_with_correct_current(client, db):
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
headers = {'Authorization': f'Bearer {token}'}
response = client.post('/api/auth/change-password',
json={'current_password': 'rightpass1',
'new_password': 'brandnew123'}, headers=headers)
assert response.status_code == 200
assert _login(client, 'normaluser', 'brandnew123').status_code == 200
def test_change_password_rejects_short_password(client, db):
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
headers = {'Authorization': f'Bearer {token}'}
response = client.post('/api/auth/change-password',
json={'current_password': 'rightpass1',
'new_password': 'short'}, headers=headers)
assert response.status_code == 400
# ---------------------------------------------------------------------------
# New-user welcome
# ---------------------------------------------------------------------------
def test_create_user_sets_mustchangepassword_and_sends(client, db, auth_headers):
_enable_smtp(db)
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
instance = MagicMock()
smtp.return_value = instance
response = client.post('/api/users',
json={'username': 'newhire',
'email': 'newhire@test.local',
'password': 'temppass1'},
headers=auth_headers)
assert response.status_code == 201
assert response.get_json()['data']['mustchangepassword'] is True
instance.sendmail.assert_called_once()
def test_create_user_survives_mail_failure(client, db, auth_headers):
"""User is still created (with a warning) when the welcome email fails."""
_enable_smtp(db)
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
instance = MagicMock()
instance.sendmail.side_effect = RuntimeError('relay down')
smtp.return_value = instance
response = client.post('/api/users',
json={'username': 'newhire2',
'email': 'newhire2@test.local',
'password': 'temppass1'},
headers=auth_headers)
assert response.status_code == 201
assert 'warning' in response.get_json()['data']
# ---------------------------------------------------------------------------
# On-demand report/alert delivery
# ---------------------------------------------------------------------------
def test_email_report_requires_permission(client, db, member_headers):
response = client.post('/api/reports/email',
json={'subject': 'X', 'columns': [], 'rows': []},
headers=member_headers)
assert response.status_code == 403
def test_email_report_renders_and_sends(client, db, auth_headers):
_enable_smtp(db)
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
instance = MagicMock()
smtp.return_value = instance
response = client.post('/api/reports/email',
json={'subject': 'Warranty Report',
'columns': [{'key': 'vendor', 'label': 'Vendor'}],
'rows': [{'vendor': 'Dell'}],
'to': 'boss@test.local'},
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['sent'] is True
instance.sendmail.assert_called_once()
def test_email_report_defaults_to_alert_recipients(client, db, auth_headers):
_enable_smtp(db)
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
instance = MagicMock()
smtp.return_value = instance
response = client.post('/api/reports/email',
json={'subject': 'Toner', 'columns': [], 'rows': []},
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['sent'] is True

View File

@@ -0,0 +1,27 @@
"""Unit tests for the location operation-code derivation.
Locations carry no dedicated code column, so the operation code is the leading
whitespace-delimited token of the location name (e.g. '0615 Blisk Inspection'
-> '0615'). Printed asset labels for measuring tools encode this so every tool
at one operation shares one code.
"""
from shopdb.core.models.location import derive_locationcode
def test_derive_leading_numeric_token():
assert derive_locationcode('0615 Blisk Inspection') == '0615'
def test_derive_single_token_name():
assert derive_locationcode('Lab') == 'Lab'
def test_derive_strips_surrounding_whitespace():
assert derive_locationcode(' 0700 Final ') == '0700'
def test_derive_none_when_blank():
assert derive_locationcode('') is None
assert derive_locationcode(' ') is None
assert derive_locationcode(None) is None

View File

@@ -158,6 +158,28 @@ def test_defaults_contain_new_site_keys():
assert by_key['contact_email_domain']['category'] == 'site'
def test_defaults_contain_label_target_keys():
"""build_default_settings seeds the per-type label targets + label defaults."""
by_key = _defaults_by_key()
for key in ('qr_target_machine', 'qr_target_computer',
'qr_target_network_device', 'qr_target_measuring_tool'):
assert key in by_key, f'missing default {key}'
assert by_key[key]['value'] == ''
assert by_key[key]['category'] == 'printing'
assert '{locationcode}' in by_key['qr_target_measuring_tool']['description']
assert by_key['label_default_style']['value'] == 'card'
assert by_key['label_default_codetype']['value'] == 'qr'
assert by_key['label_default_style']['category'] == 'printing'
assert by_key['label_default_codetype']['category'] == 'printing'
# Per-type default encode mode: machines -> their machine number,
# measuring tools -> inspection location, the rest -> asset page.
assert by_key['label_default_encodes_machine']['value'] == 'assetnumber'
assert by_key['label_default_encodes_measuring_tool']['value'] == 'location'
for assettype in ('computer', 'printer', 'network_device'):
assert by_key[f'label_default_encodes_{assettype}']['value'] == 'assetpage'
assert by_key[f'label_default_encodes_{assettype}']['category'] == 'printing'
def test_defaults_changed_facility_and_map():
"""facility_name default is now blank; map blueprints point at the placeholder."""
by_key = _defaults_by_key()

View File

@@ -202,6 +202,35 @@ def test_tool_create_and_get_merged(client, auth_headers):
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers):
"""A tool at an operation location exposes locationcode (leading token)
and locationname so a printed label can encode the inspection operation."""
with mt_app.app_context():
from shopdb.core.models import Location
location = Location(locationname='0615 Blisk Inspection')
_db.session.add(location)
_db.session.commit()
location_id = location.locationid
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client),
'locationid': location_id,
})
assert created.status_code == 201, created.get_json()
payload = created.get_json()['data']
assert payload['locationname'] == '0615 Blisk Inspection'
assert payload['locationcode'] == '0615'
def test_tool_payload_location_code_none_when_unplaced(client, auth_headers):
"""A tool with no location degrades gracefully: locationcode is None."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client),
})
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['locationcode'] is None
def test_tool_update_merged_payload(client, auth_headers):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client),