Add email sending (service + 3 flows) and a general asset label generator
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:
43
CHANGELOG.md
43
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
53
frontend/src/components/EmailReportButton.vue
Normal file
53
frontend/src/components/EmailReportButton.vue
Normal 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>
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
89
frontend/src/views/ChangePassword.vue
Normal file
89
frontend/src/views/ChangePassword.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
276
frontend/src/views/print/AssetLabel.vue
Normal file
276
frontend/src/views/print/AssetLabel.vue
Normal 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>
|
||||
99
frontend/src/views/print/assetLabel.js
Normal file
99
frontend/src/views/print/assetLabel.js
Normal 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 || ''
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '""')}"`
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
migrations/versions/7d23_user_mustchangepassword.py
Normal file
50
migrations/versions/7d23_user_mustchangepassword.py
Normal 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')
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
259
shopdb/utils/mailer.py
Normal 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('&', '&').replace('<', '<').replace('>', '>'))
|
||||
|
||||
|
||||
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)
|
||||
270
tests/test_core/test_email.py
Normal file
270
tests/test_core/test_email.py
Normal 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
|
||||
27
tests/test_core/test_location_code.py
Normal file
27
tests/test_core/test_location_code.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user