Compare commits
5 Commits
lab-stage-
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dfdb167d5 | ||
|
|
aa4bfcd41c | ||
|
|
26b6b6b32f | ||
|
|
eab225e1e6 | ||
|
|
a8a6baf979 |
@@ -43,7 +43,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
|
||||
### Active state
|
||||
|
||||
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
|
||||
- `__contract_version__` at 0.12.0 (0.12.0 adds the mailer to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||
- `__contract_version__` at 0.13.0 (0.12.0 added the mailer, 0.13.0 the User model, to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
|
||||
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
|
||||
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM).
|
||||
|
||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.12.0'
|
||||
__contract_version__ = '0.13.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -479,6 +479,8 @@ What `shopdb.api` exposes:
|
||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime`
|
||||
- Legacy employee directory: `employee_connection`
|
||||
- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
|
||||
alert recipients' emails from selected user ids or role membership
|
||||
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
|
||||
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
|
||||
email is unconfigured; send_alert targets the site's alert_recipients
|
||||
|
||||
@@ -320,6 +320,71 @@ it. The page is ordinary:
|
||||
3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` -
|
||||
the card appears in the rail's catalog while the plugin is enabled.
|
||||
|
||||
## Stage 13 (extension) - alert recipients picked from shopdb users
|
||||
|
||||
Free-text emails rot; user accounts do not. Another contract addition:
|
||||
`User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the
|
||||
docs-drift guard again).
|
||||
1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded
|
||||
beside the others.
|
||||
2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's
|
||||
account email, merge with the free-text list, dedupe order-preserving;
|
||||
empty result still falls back to the site alert_recipients.
|
||||
3. Settings page: checkbox picker over `usersApi.list()` (the page is
|
||||
admin-only, matching the endpoint), saving joined ids.
|
||||
4. Test: active user's email + free-text merge deduped, inactive user
|
||||
skipped (`test_alert_recipients_merge_users_and_freetext`).
|
||||
|
||||
## Stage 14 (extension) - retire/restore in the UI, dashless codes
|
||||
|
||||
Field feedback stage: the soft-delete endpoint existed with no button, and
|
||||
the site wanted `WJRP0042`, not `WJRP-0042`.
|
||||
1. Detail gains Retire (confirm dialog; item leaves the storefront and the
|
||||
kiosk 404s its code, history and label intact) and Restore; the list
|
||||
gains an Include-retired toggle (`?active=false`) with a Retired badge.
|
||||
Restore is its own POST gated by printedparts.delete - PUT deliberately
|
||||
cannot flip isactive.
|
||||
2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their
|
||||
codes - itemcode is an immutable label once printed on a bin.
|
||||
|
||||
## Stage 15 (extension) - print-file revisions + role-based alerts
|
||||
|
||||
Two more field requests, and the plugin's FIRST incremental migration:
|
||||
1. `printeditemfiles` (append-only revisions of the STL/3MF/gcode per item)
|
||||
arrives as `0002_printeditemfiles` on top of the 0001 baseline - the
|
||||
ADR-008 payoff: the plugin evolves its own schema, `flask plugin
|
||||
upgrade-all` applies it, the core chain never hears about it. Update
|
||||
PLUGIN_TABLE_OWNERS and the guard test's expected head.
|
||||
Gotchas hit live: (a) MySQL 5.6 dev box - a VARCHAR(255) UNIQUE on
|
||||
utf8mb4 dies with error 1071 because the per-plugin chain does not apply
|
||||
the core env's ROW_FORMAT=DYNAMIC hook; size unique columns to 191 or
|
||||
less (191*4 = 764 bytes fits the 767 prefix). (b) The dev container's
|
||||
innodb_large_prefix globals reset on restart (documented dev caveat).
|
||||
2. Upload endpoint assigns revision = max+1, stores
|
||||
`printeditem-<id>-rev<n><ext>` in `instance/printedpartsfiles/`
|
||||
(extension allowlist, 100 MB cap), records uploader from the JWT.
|
||||
Download serves the ORIGINAL filename; delete (permission-gated) exists
|
||||
for wrong-file mistakes, otherwise history is append-only. Detail page
|
||||
gains the revision table with a "current" badge on the newest.
|
||||
3. Role-based alert recipients: `Role` joins the 0.13.0 surface beside User;
|
||||
Setting `printedparts_alert_roleids`; `_alert_recipients` folds in every
|
||||
ACTIVE member of each selected role (role.users backref), deduped with
|
||||
the user picks and free-text; settings page gains a role picker.
|
||||
|
||||
## Stage 16 (extension) - kiosk touch fixes from first hands-on use
|
||||
|
||||
First real touchscreen session found two problems worth their own stage:
|
||||
1. Focus steal: the page's tap-anywhere handler refocused the hidden wedge
|
||||
input, yanking focus out of the manual-entry field the moment it was
|
||||
tapped. Guard the handler - never reclaim focus from INPUT/SELECT/
|
||||
TEXTAREA/BUTTON/A targets, only from dead space.
|
||||
2. No physical keyboard on a touchscreen: manual fallbacks now use the
|
||||
TouchKeypad. Badge entry is digits (an SSO) so the keypad covers it;
|
||||
item codes are letters+digits, solved server-side instead of building an
|
||||
alphanumeric keyboard - the digits in a minted code ARE the row id, so
|
||||
`/kiosk/item/<digits>` resolves bare digits by id. Bonus: labels printed
|
||||
under an older code prefix keep working after the prefix changes.
|
||||
|
||||
---
|
||||
|
||||
## Where each pattern lives (cheat sheet)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
shopdb-flask is at `__contract_version__ = '0.12.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
|
||||
## Phase status
|
||||
|
||||
|
||||
@@ -1144,6 +1144,9 @@ export const printedpartsApi = {
|
||||
remove(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
restore(printeditemid) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restore`)
|
||||
},
|
||||
uploadImage(printeditemid, file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
@@ -1165,5 +1168,19 @@ export const printedpartsApi = {
|
||||
},
|
||||
kioskTake(data) {
|
||||
return api.post('/printedparts/kiosk/take', data)
|
||||
},
|
||||
listFiles(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}/files`)
|
||||
},
|
||||
uploadFile(printeditemid, file, note) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (note) formData.append('note', note)
|
||||
return api.post(`/printedparts/items/${printeditemid}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
removeFile(fileid) {
|
||||
return api.delete(`/printedparts/files/${fileid}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<div class="nav-section">Displays</div>
|
||||
<a :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
||||
<a :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||
<a v-if="isPluginEnabled('printedparts')" :href="withBase('/parts-kiosk')"
|
||||
target="_blank" class="external-link">Parts Kiosk</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
</nav>
|
||||
@@ -108,6 +110,7 @@ import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
import { dashboardApi, notificationsApi } from '../api'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
import { isPluginEnabled } from '../composables/enabledPlugins'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -19,12 +19,17 @@
|
||||
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
||||
<p class="kiosk-hint">
|
||||
No scanner?
|
||||
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the code</a>
|
||||
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the number</a>
|
||||
</p>
|
||||
<div v-if="manualEntry" class="manual-row">
|
||||
<input v-model="manualCode" class="form-control" placeholder="3DP-0001"
|
||||
@keydown.enter="lookupItem(manualCode)" />
|
||||
<button class="btn btn-primary" @click="lookupItem(manualCode)">Go</button>
|
||||
<div v-if="manualEntry" class="manual-block">
|
||||
<div class="quantity-display">{{ manualCode || 'label number' }}</div>
|
||||
<TouchKeypad @digit="manualCode += $event"
|
||||
@clear="manualCode = ''"
|
||||
@backspace="manualCode = manualCode.slice(0, -1)" />
|
||||
<button class="btn btn-primary take-button" :disabled="!manualCode"
|
||||
@click="lookupItem(manualCode)">Look up</button>
|
||||
<p class="kiosk-hint">Just the number from the label - e.g. 4 for
|
||||
{{ '0004' }}; letters are added automatically.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -37,11 +42,14 @@
|
||||
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="kiosk-prompt">Scan your badge</p>
|
||||
<div class="manual-row">
|
||||
<input v-model="manualBadge" class="form-control" placeholder="or type your SSO"
|
||||
@keydown.enter="acceptBadge(manualBadge)" />
|
||||
<button class="btn btn-primary" @click="acceptBadge(manualBadge)">Next</button>
|
||||
<p class="kiosk-prompt">Scan your badge or tap in your SSO</p>
|
||||
<div class="manual-block">
|
||||
<div class="quantity-display">{{ manualBadge || 'SSO' }}</div>
|
||||
<TouchKeypad @digit="manualBadge += $event"
|
||||
@clear="manualBadge = ''"
|
||||
@backspace="manualBadge = manualBadge.slice(0, -1)" />
|
||||
<button class="btn btn-primary take-button" :disabled="!manualBadge"
|
||||
@click="acceptBadge(manualBadge)">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -96,7 +104,12 @@ let resetTimer = null
|
||||
onMounted(focusWedge)
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
|
||||
function focusWedge() {
|
||||
function focusWedge(event) {
|
||||
// Tapping a visible input/button must keep it - only reclaim focus for
|
||||
// the wedge scanner from dead space.
|
||||
const tag = event?.target?.tagName
|
||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|
||||
|| tag === 'BUTTON' || tag === 'A') return
|
||||
wedgeInput.value?.focus()
|
||||
}
|
||||
|
||||
@@ -239,4 +252,10 @@ function reset() {
|
||||
padding: 0.9rem 3.5rem;
|
||||
}
|
||||
.manual-row { display: flex; gap: 0.6rem; }
|
||||
.manual-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
{{ item.quantityonhand }} on hand
|
||||
</span>
|
||||
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
|
||||
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
||||
@@ -29,6 +30,10 @@
|
||||
class="btn btn-secondary btn-sm">Edit</router-link>
|
||||
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
|
||||
class="btn btn-secondary btn-sm">Bin Label</router-link>
|
||||
<button v-if="item.isactive" class="btn btn-danger btn-sm"
|
||||
@click="retireItem">Retire</button>
|
||||
<button v-else class="btn btn-primary btn-sm"
|
||||
@click="restoreItem">Restore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,6 +68,57 @@
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Print files</h3>
|
||||
<div class="file-upload-row">
|
||||
<input ref="fileInput" type="file"
|
||||
accept=".stl,.3mf,.gcode,.gco,.bgcode,.step,.stp,.obj,.amf" />
|
||||
<input v-model="fileNote" type="text" class="form-control"
|
||||
placeholder="What changed? (optional)" />
|
||||
<button class="btn btn-primary btn-sm" :disabled="fileUploading"
|
||||
@click="uploadRevision">
|
||||
{{ fileUploading ? 'Uploading...' : 'Upload revision' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="fileError" class="error-message">{{ fileError }}</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rev</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>By</th>
|
||||
<th>Note</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="revision in files" :key="revision.fileid"
|
||||
:class="{ 'current-revision': revision === files[0] }">
|
||||
<td>{{ revision.revision }}</td>
|
||||
<td>
|
||||
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)">
|
||||
{{ revision.filename }}
|
||||
</a>
|
||||
<span v-if="revision === files[0]" class="badge badge-success">current</span>
|
||||
</td>
|
||||
<td>{{ formatSize(revision.filesize) }}</td>
|
||||
<td :title="revision.uploadeddate">{{ revision.uploadedby }}</td>
|
||||
<td>{{ revision.uploadnote || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
@click="removeRevision(revision)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="files.length === 0">
|
||||
<td colspan="6" class="empty-state">No print file uploaded yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Recent transactions</h3>
|
||||
<div class="table-container">
|
||||
@@ -146,6 +202,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
item.value = response.data.data
|
||||
await loadFiles()
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printed item:', loadError)
|
||||
} finally {
|
||||
@@ -153,6 +210,58 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const files = ref([])
|
||||
const fileInput = ref(null)
|
||||
const fileNote = ref('')
|
||||
const fileUploading = ref(false)
|
||||
const fileError = ref('')
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const response = await printedpartsApi.listFiles(route.params.id)
|
||||
files.value = response.data.data || []
|
||||
} catch (filesError) {
|
||||
console.error('Error loading files:', filesError)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadRevision() {
|
||||
const file = fileInput.value?.files?.[0]
|
||||
if (!file) { fileError.value = 'Choose a file first'; return }
|
||||
fileUploading.value = true
|
||||
fileError.value = ''
|
||||
try {
|
||||
await printedpartsApi.uploadFile(route.params.id, file, fileNote.value)
|
||||
fileNote.value = ''
|
||||
fileInput.value.value = ''
|
||||
await loadFiles()
|
||||
} catch (uploadError) {
|
||||
fileError.value =
|
||||
uploadError.response?.data?.data?.error?.message || 'Upload failed'
|
||||
} finally {
|
||||
fileUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRevision(revision) {
|
||||
if (!window.confirm(
|
||||
`Delete revision ${revision.revision} (${revision.filename})?`)) return
|
||||
try {
|
||||
await printedpartsApi.removeFile(revision.fileid)
|
||||
await loadFiles()
|
||||
} catch (removeError) {
|
||||
fileError.value = 'Delete failed'
|
||||
console.error(removeError)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes && bytes !== 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const ledgerOpen = ref(false)
|
||||
const ledgerMode = ref('restock')
|
||||
const ledgerQuantity = ref(null)
|
||||
@@ -197,6 +306,28 @@ async function submitLedger() {
|
||||
}
|
||||
}
|
||||
|
||||
async function retireItem() {
|
||||
if (!window.confirm(
|
||||
`Retire ${item.value.itemname}? It leaves the storefront and kiosk; `
|
||||
+ 'history and the bin label stay, and it can be restored later.')) return
|
||||
try {
|
||||
await printedpartsApi.remove(item.value.printeditemid)
|
||||
const response = await printedpartsApi.get(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (retireError) {
|
||||
console.error('Retire failed:', retireError)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreItem() {
|
||||
try {
|
||||
const response = await printedpartsApi.restore(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (restoreError) {
|
||||
console.error('Restore failed:', restoreError)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
@@ -206,5 +337,13 @@ function formatDate(value) {
|
||||
<style scoped>
|
||||
.hero-actions { margin-top: 0.75rem; }
|
||||
.qty-out { color: var(--danger); }
|
||||
.file-upload-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.current-revision td { font-weight: 600; }
|
||||
.qty-in { color: var(--success); }
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
||||
Low stock only
|
||||
</label>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="includeRetired" type="checkbox" @change="loadItems" />
|
||||
Include retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -56,7 +60,10 @@
|
||||
/>
|
||||
</td>
|
||||
<td>{{ item.itemcode || '-' }}</td>
|
||||
<td>{{ item.itemname }}</td>
|
||||
<td>
|
||||
{{ item.itemname }}
|
||||
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }}
|
||||
@@ -92,6 +99,7 @@ import { withBase } from '../../utils/basePath'
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const lowstockOnly = ref(false)
|
||||
const includeRetired = ref(false)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
@@ -106,6 +114,7 @@ async function loadItems() {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (lowstockOnly.value) params.lowstock = 'true'
|
||||
if (includeRetired.value) params.active = 'false'
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<input v-model="values.printedparts_code_prefix" type="text"
|
||||
class="form-control" maxlength="8" />
|
||||
<p class="field-hint">
|
||||
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}-0042.
|
||||
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
|
||||
Changing it does not rename existing items.
|
||||
</p>
|
||||
</div>
|
||||
@@ -34,7 +34,39 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Low-stock alert recipients</label>
|
||||
<label>Alert shopdb users</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
||||
<input type="checkbox" :value="String(candidate.userid)"
|
||||
v-model="selectedUserids" />
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="user-email">{{ candidate.email }}</span>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Selected users receive low-stock alerts at their account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert roles</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
||||
<input type="checkbox" :value="String(role.roleid)"
|
||||
v-model="selectedRoleids" />
|
||||
<span>{{ role.rolename }}</span>
|
||||
<span class="user-email">{{ role.description }}</span>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Every active member of a selected role receives low-stock alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Additional alert emails</label>
|
||||
<input v-model="values.printedparts_alert_email" type="text"
|
||||
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
|
||||
<p class="field-hint">
|
||||
@@ -53,21 +85,29 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import { settingsApi, usersApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email'
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
'printedparts_alert_roleids'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
printedparts_code_prefix: '3DP',
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: ''
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
printedparts_alert_roleids: ''
|
||||
})
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
const roles = ref([])
|
||||
const selectedRoleids = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
@@ -81,6 +121,15 @@ onMounted(async () => {
|
||||
}
|
||||
values.value.printedparts_default_threshold =
|
||||
parseInt(values.value.printedparts_default_threshold, 10) || 0
|
||||
selectedUserids.value = (values.value.printedparts_alert_userids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const usersResponse = await usersApi.list()
|
||||
users.value = (usersResponse.data.data || []).filter(
|
||||
candidate => candidate.isactive && candidate.email)
|
||||
selectedRoleids.value = (values.value.printedparts_alert_roleids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const rolesResponse = await usersApi.roles.list()
|
||||
roles.value = rolesResponse.data.data || []
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
@@ -92,6 +141,8 @@ async function save() {
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
|
||||
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||
}
|
||||
@@ -106,4 +157,21 @@ async function save() {
|
||||
|
||||
<style scoped>
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
||||
</style>
|
||||
|
||||
@@ -91,7 +91,7 @@ def _imagedir():
|
||||
def _mint_itemcode(item):
|
||||
"""Set itemcode from the configured prefix + the flushed row id."""
|
||||
prefix = Setting.get('printedparts_code_prefix') or '3DP'
|
||||
item.itemcode = f'{prefix}-{item.printeditemid:04d}'
|
||||
item.itemcode = f'{prefix}{item.printeditemid:04d}'
|
||||
|
||||
|
||||
@printedparts_bp.route('/items', methods=['POST'])
|
||||
@@ -159,6 +159,20 @@ def delete_item(item_id: int):
|
||||
return success_response(message='Printed item retired')
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/restore', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def restore_item(item_id: int):
|
||||
"""Bring a retired item back; code, photo, and history are intact."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
item.isactive = True
|
||||
db.session.commit()
|
||||
return success_response(item.to_dict(), message='Printed item restored')
|
||||
|
||||
|
||||
# --- item image: the models.py upload/serve/delete trio ---------------------
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||
@@ -250,6 +264,36 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None)
|
||||
_send_lowstock_alert(item)
|
||||
|
||||
|
||||
def _alert_recipients():
|
||||
"""Merge selected shopdb users' account emails with the free-text list.
|
||||
|
||||
Empty result means fall back to the site-wide alert_recipients."""
|
||||
from shopdb.api import User, Role
|
||||
recipients = []
|
||||
userids = (Setting.get('printedparts_alert_userids') or '').strip()
|
||||
for rawid in userids.split(','):
|
||||
rawid = rawid.strip()
|
||||
if not rawid.isdigit():
|
||||
continue
|
||||
user = db.session.get(User, int(rawid))
|
||||
if user and user.isactive and user.email:
|
||||
recipients.append(user.email)
|
||||
roleids = (Setting.get('printedparts_alert_roleids') or '').strip()
|
||||
for rawid in roleids.split(','):
|
||||
rawid = rawid.strip()
|
||||
if not rawid.isdigit():
|
||||
continue
|
||||
role = db.session.get(Role, int(rawid))
|
||||
if role:
|
||||
recipients.extend(member.email for member in role.users
|
||||
if member.isactive and member.email)
|
||||
extra = (Setting.get('printedparts_alert_email') or '').strip()
|
||||
recipients.extend(address.strip() for address in extra.split(',')
|
||||
if address.strip())
|
||||
# dedupe, order-preserving
|
||||
return list(dict.fromkeys(recipients))
|
||||
|
||||
|
||||
def _send_lowstock_alert(item):
|
||||
"""Best-effort email when an item crosses its low-stock threshold.
|
||||
|
||||
@@ -265,10 +309,9 @@ def _send_lowstock_alert(item):
|
||||
f'<p>Bin: {item.binlocation or "-"}</p>'
|
||||
f'<p>Time to print more.</p>')
|
||||
try:
|
||||
recipients = (Setting.get('printedparts_alert_email') or '').strip()
|
||||
recipients = _alert_recipients()
|
||||
if recipients:
|
||||
send_email([address.strip() for address in recipients.split(',')
|
||||
if address.strip()], subject, html)
|
||||
send_email(recipients, subject, html)
|
||||
else:
|
||||
send_alert(subject, html)
|
||||
except Exception:
|
||||
@@ -338,12 +381,28 @@ def adjust_item(item_id: int):
|
||||
# physically rate-limited. It can reduce stock of an active item and nothing
|
||||
# else; identity comes from the badge resolved server-side, never the client.
|
||||
|
||||
def _kiosk_find_item(itemcode):
|
||||
"""Resolve a scanned or typed code to an active item.
|
||||
|
||||
Accepts the full code (WJRP0042) or bare digits from the touch keypad
|
||||
(42 -> prefix + zero-pad), so manual entry never needs letters."""
|
||||
itemcode = (itemcode or '').strip()
|
||||
item = PrintedItem.query.filter(
|
||||
PrintedItem.itemcode == itemcode,
|
||||
PrintedItem.isactive == True).first()
|
||||
if not item and itemcode.isdigit():
|
||||
# The digits in a minted code ARE the row id, so id lookup keeps
|
||||
# working even for labels printed under an older prefix.
|
||||
candidate = db.session.get(PrintedItem, int(itemcode))
|
||||
if candidate and candidate.isactive:
|
||||
item = candidate
|
||||
return item
|
||||
|
||||
|
||||
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
|
||||
def kiosk_item(itemcode):
|
||||
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
|
||||
item = PrintedItem.query.filter(
|
||||
PrintedItem.itemcode == itemcode.strip(),
|
||||
PrintedItem.isactive == True).first()
|
||||
item = _kiosk_find_item(itemcode)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No part matches that barcode', http_code=404)
|
||||
@@ -355,9 +414,7 @@ def kiosk_take():
|
||||
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
item = PrintedItem.query.filter(
|
||||
PrintedItem.itemcode == (data.get('itemcode') or '').strip(),
|
||||
PrintedItem.isactive == True).first()
|
||||
item = _kiosk_find_item(data.get('itemcode'))
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No part matches that barcode', http_code=404)
|
||||
@@ -491,3 +548,126 @@ def report_by_person():
|
||||
if request.args.get('format') == 'csv':
|
||||
return _csv_response(rows, columns, 'printedparts-by-person.csv')
|
||||
return success_response({'columns': columns, 'rows': rows, 'days': days})
|
||||
|
||||
|
||||
# --- print files: append-only revisions per item ------------------------------
|
||||
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
|
||||
from ..models import PrintedItemFile
|
||||
|
||||
FILE_EXTENSIONS = {'.stl', '.3mf', '.gcode', '.gco', '.bgcode', '.step',
|
||||
'.stp', '.obj', '.amf'}
|
||||
MAX_FILE_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def _filedir():
|
||||
return os.path.join(current_app.instance_path, 'printedpartsfiles')
|
||||
|
||||
|
||||
def _uploader_name():
|
||||
from shopdb.api import User
|
||||
identity = get_jwt_identity()
|
||||
try:
|
||||
user = db.session.get(User, int(identity))
|
||||
if user:
|
||||
return user.username
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return str(identity)
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_item_files(item_id: int):
|
||||
"""Revision history, newest first."""
|
||||
files = (PrintedItemFile.query.filter_by(printeditemid=item_id)
|
||||
.order_by(PrintedItemFile.revision.desc()).all())
|
||||
return success_response([f.to_dict() for f in files])
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.edit')
|
||||
def upload_item_file(item_id: int):
|
||||
"""Upload the next revision of the item's print file.
|
||||
|
||||
multipart/form-data: file=<stl/3mf/gcode/...>, note=<what changed>.
|
||||
Revisions are append-only; nothing is replaced.
|
||||
"""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in FILE_EXTENSIONS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unsupported file type {ext}; allowed: '
|
||||
+ ', '.join(sorted(FILE_EXTENSIONS)))
|
||||
|
||||
upload.stream.seek(0, os.SEEK_END)
|
||||
filesize = upload.stream.tell()
|
||||
upload.stream.seek(0)
|
||||
if filesize > MAX_FILE_BYTES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'File exceeds the 100 MB limit')
|
||||
|
||||
latest = (db.session.query(db.func.max(PrintedItemFile.revision))
|
||||
.filter_by(printeditemid=item_id).scalar()) or 0
|
||||
revision = latest + 1
|
||||
|
||||
filedir = _filedir()
|
||||
os.makedirs(filedir, exist_ok=True)
|
||||
storedfilename = secure_filename(
|
||||
f'printeditem-{item_id}-rev{revision}{ext}')
|
||||
upload.save(os.path.join(filedir, storedfilename))
|
||||
|
||||
record = PrintedItemFile(
|
||||
printeditemid=item_id,
|
||||
revision=revision,
|
||||
filename=secure_filename(upload.filename),
|
||||
storedfilename=storedfilename,
|
||||
filesize=filesize,
|
||||
uploadnote=(request.form.get('note') or '').strip() or None,
|
||||
uploadedby=_uploader_name(),
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
return success_response(record.to_dict(),
|
||||
message=f'Revision {revision} uploaded',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def download_item_file(file_id: int):
|
||||
"""Download a revision under its original filename."""
|
||||
from flask import send_from_directory
|
||||
record = db.session.get(PrintedItemFile, file_id)
|
||||
if not record:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
|
||||
http_code=404)
|
||||
return send_from_directory(_filedir(), record.storedfilename,
|
||||
as_attachment=True,
|
||||
download_name=record.filename)
|
||||
|
||||
|
||||
@printedparts_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def delete_item_file(file_id: int):
|
||||
"""Remove a bad revision (wrong file uploaded). History otherwise stays."""
|
||||
record = db.session.get(PrintedItemFile, file_id)
|
||||
if not record:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
|
||||
http_code=404)
|
||||
path = os.path.join(_filedir(), record.storedfilename)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
db.session.delete(record)
|
||||
db.session.commit()
|
||||
return success_response(message='Revision removed')
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add printeditemfiles: append-only print-file revisions per item.
|
||||
|
||||
The plugin's first incremental migration on top of its 0001 baseline -
|
||||
the ADR-008 payoff: the plugin evolves its own schema without touching
|
||||
the core chain. Applied by `flask plugin upgrade-all`.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printedparts0002files'
|
||||
down_revision = 'printedparts0001baseline'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'printeditemfiles',
|
||||
sa.Column('fileid', sa.Integer(), nullable=False),
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
sa.Column('revision', sa.Integer(), nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||
sa.Column('storedfilename', sa.String(length=191), nullable=False),
|
||||
sa.Column('filesize', sa.Integer(), nullable=False),
|
||||
sa.Column('uploadnote', sa.String(length=255), nullable=True),
|
||||
sa.Column('uploadedby', sa.String(length=80), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['printeditemid'],
|
||||
['printeditems.printeditemid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('fileid'),
|
||||
sa.UniqueConstraint('storedfilename'),
|
||||
)
|
||||
op.create_index('ix_printeditemfiles_printeditemid',
|
||||
'printeditemfiles', ['printeditemid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('printeditemfiles')
|
||||
@@ -1,5 +1,11 @@
|
||||
"""Printedparts plugin models."""
|
||||
|
||||
from .printeditem import PrintedItem, PrintedItemTransaction, TRANSACTION_TYPES
|
||||
from .printeditem import (
|
||||
PrintedItem,
|
||||
PrintedItemTransaction,
|
||||
PrintedItemFile,
|
||||
TRANSACTION_TYPES,
|
||||
)
|
||||
|
||||
__all__ = ['PrintedItem', 'PrintedItemTransaction', 'TRANSACTION_TYPES']
|
||||
__all__ = ['PrintedItem', 'PrintedItemTransaction', 'PrintedItemFile',
|
||||
'TRANSACTION_TYPES']
|
||||
|
||||
@@ -93,3 +93,43 @@ class PrintedItemTransaction(BaseModel):
|
||||
'reason': self.reason,
|
||||
'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None,
|
||||
}
|
||||
|
||||
|
||||
class PrintedItemFile(BaseModel):
|
||||
"""One uploaded revision of an item's print file (STL/3MF/gcode/...).
|
||||
|
||||
Revisions are append-only per item: uploading assigns the next revision
|
||||
number and never replaces earlier files, so the history of what was
|
||||
actually printed stays reconstructible. The current file is simply the
|
||||
highest revision.
|
||||
"""
|
||||
|
||||
__tablename__ = 'printeditemfiles'
|
||||
|
||||
fileid = db.Column(db.Integer, primary_key=True)
|
||||
printeditemid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
revision = db.Column(db.Integer, nullable=False)
|
||||
filename = db.Column(db.String(255), nullable=False,
|
||||
comment='Original upload name, used for download')
|
||||
storedfilename = db.Column(db.String(191), nullable=False, unique=True,
|
||||
comment='191: unique index fits the 767-byte MySQL prefix')
|
||||
filesize = db.Column(db.Integer, nullable=False)
|
||||
uploadnote = db.Column(db.String(255),
|
||||
comment='What changed in this revision')
|
||||
uploadedby = db.Column(db.String(80), nullable=False,
|
||||
comment='Username of the uploader')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'fileid': self.fileid,
|
||||
'printeditemid': self.printeditemid,
|
||||
'revision': self.revision,
|
||||
'filename': self.filename,
|
||||
'filesize': self.filesize,
|
||||
'uploadnote': self.uploadnote,
|
||||
'uploadedby': self.uploadedby,
|
||||
'uploadeddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ from flask import Flask, Blueprint
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, Setting
|
||||
|
||||
from .models import PrintedItem, PrintedItemTransaction
|
||||
from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
|
||||
from .api import printedparts_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,7 +46,7 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
return printedparts_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [PrintedItem, PrintedItemTransaction]
|
||||
return [PrintedItem, PrintedItemTransaction, PrintedItemFile]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
||||
@@ -133,6 +133,12 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
('printedparts_alert_email', '', 'string',
|
||||
'Comma-separated low-stock alert recipients; empty uses the '
|
||||
'site alert_recipients'),
|
||||
('printedparts_alert_userids', '', 'string',
|
||||
'Comma-separated shopdb user ids whose account emails receive '
|
||||
'low-stock alerts'),
|
||||
('printedparts_alert_roleids', '', 'string',
|
||||
'Comma-separated role ids; every active member of these roles '
|
||||
'receives low-stock alerts'),
|
||||
]
|
||||
for key, value, valuetype, description in defaults:
|
||||
if Setting.get(key) is None:
|
||||
|
||||
@@ -36,7 +36,7 @@ from .plugins import plugin_manager
|
||||
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
||||
# managed service token without importing core token internals. Additive name
|
||||
# on the import surface, minor bump.
|
||||
__contract_version__ = '0.12.0'
|
||||
__contract_version__ = '0.13.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -44,6 +44,8 @@ from shopdb.core.models import (
|
||||
OperatingSystem,
|
||||
AssetRelationship,
|
||||
RelationshipType,
|
||||
User,
|
||||
Role,
|
||||
)
|
||||
|
||||
# Response + pagination helpers for plugin API blueprints
|
||||
@@ -269,6 +271,8 @@ __all__ = [
|
||||
'employee_connection',
|
||||
'send_email',
|
||||
'send_alert',
|
||||
'User',
|
||||
'Role',
|
||||
# CMMC USB check-in/out database
|
||||
'cmmc_usb_connection',
|
||||
]
|
||||
|
||||
@@ -53,7 +53,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'printedparts': ('printeditems', 'printeditemtransactions'),
|
||||
'printedparts': ('printeditems', 'printeditemtransactions',
|
||||
'printeditemfiles'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
|
||||
@@ -55,7 +55,7 @@ EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0002files'
|
||||
# notifications indexes businessunitid on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_create_mints_itemcode(client, auth_headers):
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()['data']
|
||||
assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}"
|
||||
assert data['itemcode'] == f"3DP{data['printeditemid']:04d}"
|
||||
assert data['quantityonhand'] == 0
|
||||
|
||||
|
||||
@@ -200,3 +200,129 @@ def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
|
||||
restock(20) # 22: rearmed
|
||||
assert take(18).status_code == 200 # 4: crosses again -> second alert
|
||||
assert sent == ['3DP-9001', '3DP-9001']
|
||||
|
||||
|
||||
def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
|
||||
item, directory_employee,
|
||||
monkeypatch):
|
||||
"""Selected shopdb users' account emails merge with the free-text list,
|
||||
deduped; inactive users are skipped."""
|
||||
import shopdb.api as contract_surface
|
||||
captured = {}
|
||||
monkeypatch.setattr(contract_surface, 'send_email',
|
||||
lambda to, subject, html, text=None:
|
||||
captured.setdefault('to', to) or True)
|
||||
|
||||
with app.app_context():
|
||||
from shopdb.api import User
|
||||
from werkzeug.security import generate_password_hash
|
||||
active = User(username='partslead', email='lead@site.test',
|
||||
passwordhash=generate_password_hash('x'), isactive=True)
|
||||
inactive = User(username='oldtimer', email='gone@site.test',
|
||||
passwordhash=generate_password_hash('x'),
|
||||
isactive=False)
|
||||
db.session.add_all([active, inactive])
|
||||
db.session.commit()
|
||||
Setting.set('printedparts_alert_userids',
|
||||
f'{active.userid},{inactive.userid}',
|
||||
valuetype='string', category='printedparts')
|
||||
Setting.set('printedparts_alert_email',
|
||||
'extra@site.test, lead@site.test',
|
||||
valuetype='string', category='printedparts')
|
||||
db.session.commit()
|
||||
|
||||
client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 10, 'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
take = client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee, 'quantity': 6})
|
||||
assert take.status_code == 200 # 4 on hand: crossed threshold 5
|
||||
|
||||
assert captured['to'] == ['lead@site.test', 'extra@site.test']
|
||||
|
||||
|
||||
def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
||||
"""Retire drops the item from the default list and the kiosk; restore
|
||||
brings it back with history intact."""
|
||||
assert client.delete(f'/api/printedparts/items/{item}',
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
listed = client.get('/api/printedparts/items').get_json()['data']
|
||||
assert all(row['printeditemid'] != item for row in listed)
|
||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
|
||||
assert kiosk.status_code == 404
|
||||
|
||||
including = client.get('/api/printedparts/items?active=false')
|
||||
assert any(row['printeditemid'] == item
|
||||
for row in including.get_json()['data'])
|
||||
|
||||
assert client.post(f'/api/printedparts/items/{item}/restore',
|
||||
headers=auth_headers).status_code == 200
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
|
||||
|
||||
|
||||
def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path):
|
||||
"""Uploads mint sequential revisions; download returns the original name."""
|
||||
import io
|
||||
|
||||
first = client.post(f'/api/printedparts/items/{item}/files',
|
||||
data={'file': (io.BytesIO(b'solid part'), 'clip_v1.stl'),
|
||||
'note': 'initial'},
|
||||
headers=auth_headers,
|
||||
content_type='multipart/form-data')
|
||||
assert first.status_code == 201, first.get_json()
|
||||
assert first.get_json()['data']['revision'] == 1
|
||||
|
||||
second = client.post(f'/api/printedparts/items/{item}/files',
|
||||
data={'file': (io.BytesIO(b'G1 X0 Y0'), 'clip_v2.gcode')},
|
||||
headers=auth_headers,
|
||||
content_type='multipart/form-data')
|
||||
assert second.get_json()['data']['revision'] == 2
|
||||
|
||||
bad = client.post(f'/api/printedparts/items/{item}/files',
|
||||
data={'file': (io.BytesIO(b'x'), 'malware.exe')},
|
||||
headers=auth_headers,
|
||||
content_type='multipart/form-data')
|
||||
assert bad.status_code == 400
|
||||
|
||||
listing = client.get(f'/api/printedparts/items/{item}/files').get_json()['data']
|
||||
assert [f['revision'] for f in listing] == [2, 1]
|
||||
|
||||
fileid = listing[1]['fileid']
|
||||
download = client.get(f'/api/printedparts/files/{fileid}/download')
|
||||
assert download.status_code == 200
|
||||
assert download.data == b'solid part'
|
||||
assert 'clip_v1.stl' in download.headers['Content-Disposition']
|
||||
|
||||
|
||||
def test_alert_role_members_receive(client, auth_headers, app, item,
|
||||
directory_employee, monkeypatch):
|
||||
"""Every active member of a selected role gets the alert."""
|
||||
import shopdb.api as contract_surface
|
||||
captured = {}
|
||||
monkeypatch.setattr(contract_surface, 'send_email',
|
||||
lambda to, subject, html, text=None:
|
||||
captured.setdefault('to', to) or True)
|
||||
|
||||
with app.app_context():
|
||||
from shopdb.api import User, Role
|
||||
from werkzeug.security import generate_password_hash
|
||||
role = Role(rolename='partscrew', description='3D parts crew')
|
||||
member = User(username='crewone', email='crewone@site.test',
|
||||
passwordhash=generate_password_hash('x'), isactive=True)
|
||||
member.roles.append(role)
|
||||
db.session.add_all([role, member])
|
||||
db.session.commit()
|
||||
Setting.set('printedparts_alert_roleids', str(role.roleid),
|
||||
valuetype='string', category='printedparts')
|
||||
db.session.commit()
|
||||
|
||||
client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 10, 'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
take = client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee, 'quantity': 6})
|
||||
assert take.status_code == 200
|
||||
assert captured['to'] == ['crewone@site.test']
|
||||
|
||||
Reference in New Issue
Block a user