7 Commits

Author SHA1 Message Date
cproudlock
b68e927ef6 printedparts stage 9: reports - stock w/ reconcile, consumption, by-person
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Three jwt-optional endpoints with ?format=csv, merged into the reports
hub via get_reports while the plugin is enabled. The stock report's
ledgerdelta column is the reconcile check: 0 for every item whose
stock moved through the ledger, nonzero for anything that bypassed it
(the hand-seeded dev rows demonstrate the catch). MySQL SUM returns
Decimal - cast to int or the delta serializes as a string.
2026-07-17 08:04:09 -04:00
cproudlock
6439d1ccd9 printedparts stage 8: 1x0.5in bin labels
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
New public print view at /print/printedparts-labels following the
plugin-owned USB label precedent: multi-select with per-item copies,
CODE128 of the item code via JsBarcode (a QR at this size is at the
edge of scanner tolerance), one label per page on 1in x 0.5in roll
stock via a new @page size. The Detail page's Bin Label button
preselects its item through ?item=<id>; the list header gains a batch
Print Labels button.
2026-07-17 08:00:35 -04:00
cproudlock
6ed3da1b64 printedparts stage 7: the kiosk - scan bin, scan badge, keypad, take
Some checks failed
CI / backend (push) Failing after 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Two open endpoints: an item lookup by scanned code and the take POST -
the product's first unauthenticated write, held to the decision
record's bar (decrement-only, badge-attributed server-side, bounded,
physically rate-limited; justification in the plugin README). The
/parts-kiosk route is a full-screen no-auth view beside /shopfloor: a
hidden always-focused input consumes keyboard-wedge scans for
whichever step is active, TouchKeypad (net-new 3x4 grid) takes the
quantity, and a success screen resets after a few seconds. Manual
type-in fallbacks cover damaged labels. Kiosk test proves open access,
the over-take guard, the badge policy, and cache==ledger afterward.
2026-07-17 07:49:13 -04:00
cproudlock
d6a78a72ff printedparts stage 6: RBAC - declared permissions gate every mutation
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
get_permissions declares view/create/edit/delete/restock (seeded on
install/enable and by flask seed permissions); every write route adds
require_permission on top of jwt_required. New test proves
authentication alone is not authorization: a role-less member gets
403 where an admin succeeds.
2026-07-17 07:42:07 -04:00
cproudlock
6dfc8906c4 printedparts stage 5: the ledger - restock/adjust with badge attribution
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Badge resolver copied from the USB contract (SSO digits, 0<digits>BZ
PayNo wrap) with names from the employees directory and the
unknown-badge policy setting; deliberately copied rather than
cross-imported so the contract test stays green. Restock and adjust
write the ledger row and move the cached quantity in one commit -
the single-commit invariant every write path must use. Adjust
requires a reason and refuses to drive stock below zero. Detail page
gains Restock/Adjust modals. Seven tests cover minting, the
cache==ledger invariant, badge shapes, policy toggle, and auth.
2026-07-17 07:41:18 -04:00
cproudlock
cb367a38f9 printedparts stage 4: catalog mutations, item photos, detail + form
Some checks failed
CI / backend (push) Failing after 1m40s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
POST/PUT/DELETE for items: create mints the itemcode from the
configured prefix plus the flushed row id, update refuses
quantityonhand (ledger-managed - restock/adjust arrive next stage),
delete soft-retires. The image upload/serve/delete trio replicates the
models.py pattern into instance/printedpartsimages/ with a public GET.
PrintedItemDetail follows the unified detail skeleton (hero photo,
info list, transaction history table); PrintedItemForm covers
create/edit plus photo management on edit.
2026-07-17 07:36:54 -04:00
cproudlock
d1c844d533 printedparts stage 3: read API + list page (first visible win)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
GET /items (paginated, search across code/name/description/bin,
lowstock filter) and GET /items/<id> with recent transactions, both
open reads. printedpartsApi client, router file repointed at the
renamed views, PrintedItemsList with image thumbs and a red/green
quantity badge against the per-item threshold. Nav entry '3D Parts'
with a new 'box' Lucide icon mapping (the sidebar renders nothing for
unknown icon names - lab gotcha).
2026-07-16 17:10:42 -04:00
19 changed files with 1825 additions and 533 deletions

View File

@@ -1126,3 +1126,44 @@ export const measuringtoolsApi = {
}
}
}
// 3D printed parts (printedparts plugin)
export const printedpartsApi = {
list(params = {}) {
return api.get('/printedparts/items', { params })
},
get(printeditemid) {
return api.get(`/printedparts/items/${printeditemid}`)
},
create(data) {
return api.post('/printedparts/items', data)
},
update(printeditemid, data) {
return api.put(`/printedparts/items/${printeditemid}`, data)
},
remove(printeditemid) {
return api.delete(`/printedparts/items/${printeditemid}`)
},
uploadImage(printeditemid, file) {
const formData = new FormData()
formData.append('file', file)
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
deleteImage(printeditemid) {
return api.delete(`/printedparts/items/${printeditemid}/image`)
},
restock(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/restock`, data)
},
adjust(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
},
kioskItem(itemcode) {
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
},
kioskTake(data) {
return api.post('/printedparts/kiosk/take', data)
}
}

View File

@@ -0,0 +1,38 @@
<template>
<div class="touch-keypad">
<button v-for="digit in digits" :key="digit" type="button"
class="keypad-button" @click="$emit('digit', digit)">
{{ digit }}
</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('clear')">C</button>
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('backspace')">&lt;</button>
</div>
</template>
<script setup>
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
defineEmits(['digit', 'clear', 'backspace'])
</script>
<style scoped>
.touch-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
max-width: 20rem;
}
.keypad-button {
font-size: 1.8rem;
padding: 1rem 0;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
}
.keypad-button:active { background: var(--primary); color: #fff; }
.keypad-muted { color: var(--text-light); }
</style>

View File

@@ -65,6 +65,14 @@ const routes = [
name: 'shopfloor',
component: () => import('../views/ShopfloorDashboard.vue')
},
{
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
// Open on purpose - see the decision record in the printedparts proposal.
path: '/parts-kiosk',
name: 'parts-kiosk',
component: () => import('../views/printedparts/PartsKiosk.vue'),
meta: { plugin: 'printedparts' }
},
{
path: '/tv',
name: 'tv',
@@ -109,6 +117,12 @@ const routes = [
component: () => import('../views/print/USBLabelBatch.vue'),
meta: { plugin: 'usb' }
},
{
path: '/print/printedparts-labels',
name: 'print-printedparts-labels',
component: () => import('../views/print/PrintedPartsLabels.vue'),
meta: { plugin: 'printedparts' }
},
{
path: '/',
component: AppLayout,

View File

@@ -11,25 +11,25 @@ export default [
{
path: 'printedparts',
name: 'printedparts',
component: () => import('../../views/printedparts/PrintedpartsList.vue'),
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
meta: { plugin: 'printedparts' }
},
{
path: 'printedparts/new',
name: 'printedparts-new',
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/:id',
name: 'printedparts-detail',
component: () => import('../../views/printedparts/PrintedpartsDetail.vue'),
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
meta: { plugin: 'printedparts' }
},
{
path: 'printedparts/:id/edit',
name: 'printedparts-edit',
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
}
]

View File

@@ -101,7 +101,7 @@ import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
KeyRound, LogOut
Box, KeyRound, LogOut
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
@@ -147,6 +147,7 @@ const iconMap = {
'image': Image,
'shield': ShieldCheck,
'ruler': Ruler,
'box': Box,
}
// Default navigation (used as fallback if API fails)

View File

@@ -0,0 +1,207 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
<p>
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of
the item code, scannable at the parts kiosk.
</p>
<div v-if="loading" class="loading-msg">Loading parts...</div>
<div v-else-if="items.length === 0" class="loading-msg">No parts found</div>
<div v-else class="parts-grid">
<div
v-for="item in items"
:key="item.printeditemid"
class="part-item"
:class="{ selected: isSelected(item) }"
@click="toggleItem(item)"
>
<input type="checkbox" :checked="isSelected(item)" @click.stop />
<label>
<strong><code>{{ item.itemcode }}</code></strong>
<div class="alias">{{ item.itemname }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedItems.length }}</span> labels
<label class="copies-label">Copies each:
<input v-model.number="copies" type="number" min="1" max="10" />
</label>
</div>
<button class="print-btn" :disabled="selectedItems.length === 0"
@click="print">Print Labels</button>
<button class="clear-btn" @click="selectedItems = []">Clear All</button>
<button class="select-all-btn" @click="selectedItems = [...items]">Select All</button>
</div>
</div>
<div class="labels-container">
<div v-for="(label, index) in printLabels" :key="index" class="bin-label">
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg>
<div class="bin-code">{{ label.itemcode }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import JsBarcode from 'jsbarcode'
import { printedpartsApi } from '../../api'
const items = ref([])
const selectedItems = ref([])
const copies = ref(1)
const loading = ref(true)
const barcodeElements = ref({})
onMounted(async () => {
try {
const response = await printedpartsApi.list({ perpage: 500 })
items.value = response.data.data || []
// ?item=<id> preselects one part (the Detail-page print button)
const preselect = new URLSearchParams(window.location.search).get('item')
if (preselect) {
const match = items.value.find(
candidate => String(candidate.printeditemid) === preselect)
if (match) selectedItems.value = [match]
}
} catch (error) {
console.error('Error loading parts:', error)
} finally {
loading.value = false
}
})
const printLabels = computed(() => {
const labels = []
for (const item of selectedItems.value) {
for (let copy = 0; copy < Math.max(1, copies.value); copy++) {
labels.push(item)
}
}
return labels
})
function isSelected(item) {
return selectedItems.value.some(
candidate => candidate.printeditemid === item.printeditemid)
}
function toggleItem(item) {
if (isSelected(item)) {
selectedItems.value = selectedItems.value.filter(
candidate => candidate.printeditemid !== item.printeditemid)
} else {
selectedItems.value = [...selectedItems.value, item]
}
}
function setBarcodeElement(element, index) {
if (element) barcodeElements.value[index] = element
}
watch(printLabels, async labels => {
await nextTick()
labels.forEach((label, index) => {
const element = barcodeElements.value[index]
if (element) {
// CODE128 of the short item code fits 1x0.5in with comfortable
// scanner tolerance; a QR at this size would be marginal.
JsBarcode(element, label.itemcode, {
format: 'CODE128',
displayValue: false,
width: 1.4,
height: 26,
margin: 0
})
}
})
}, { deep: true })
function print() {
window.print()
}
</script>
<style scoped>
.controls {
max-width: 46rem;
margin: 1rem auto;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.parts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.5rem;
max-height: 20rem;
overflow-y: auto;
margin: 1rem 0;
}
.part-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.35rem;
cursor: pointer;
}
.part-item.selected { border-color: var(--primary); }
.alias { color: var(--text-light); font-size: 0.85rem; }
.selected-count { margin: 0.75rem 0; }
.copies-label { margin-left: 1.25rem; }
.copies-label input { width: 4rem; padding: 0.25rem; }
.print-btn, .clear-btn, .select-all-btn {
margin-right: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
.loading-msg { color: var(--text-light); padding: 1rem; }
/* screen preview of the labels */
.labels-container { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1rem; }
.bin-label {
width: 1in;
height: 0.5in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fff;
outline: 1px dashed #bbb;
}
.bin-barcode { width: 0.92in; height: 0.3in; }
.bin-code {
font-size: 6.5pt;
font-family: monospace;
color: #000;
line-height: 1;
}
/* 1in x 0.5in roll stock: one label per page */
@media print {
.no-print { display: none; }
.labels-container { display: block; padding: 0; gap: 0; }
.bin-label {
outline: none;
page-break-after: always;
break-after: page;
}
}
</style>
<style>
@media print {
@page { size: 1in 0.5in; margin: 0; }
body { margin: 0; }
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="parts-kiosk" @click="focusWedge">
<!-- keyboard-wedge scanners type the code + Enter into this hidden,
always-focused input; whichever step is active consumes the scan -->
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
<header class="kiosk-header">
<h1>3D Printed Parts</h1>
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
Start over
</button>
</header>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<!-- step 1: scan the bin -->
<section v-if="step === 'item'" class="kiosk-step">
<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>
</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>
</section>
<!-- step 2: badge -->
<section v-else-if="step === 'badge'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<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>
</div>
</section>
<!-- step 3: quantity -->
<section v-else-if="step === 'quantity'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">How many are you taking?</p>
<div class="quantity-display">{{ quantity || '0' }}</div>
<TouchKeypad @digit="quantity += $event"
@clear="quantity = ''"
@backspace="quantity = quantity.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
@click="submitTake">
{{ submitting ? 'Working...' : 'TAKE' }}
</button>
</section>
<!-- done -->
<section v-else-if="step === 'done'" class="kiosk-step">
<p class="kiosk-success">Done - {{ doneMessage }}</p>
<p class="kiosk-hint">Starting over in a few seconds...</p>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import TouchKeypad from '../../components/TouchKeypad.vue'
const step = ref('item')
const item = ref(null)
const badge = ref('')
const quantity = ref('')
const error = ref('')
const doneMessage = ref('')
const submitting = ref(false)
const manualEntry = ref(false)
const manualCode = ref('')
const manualBadge = ref('')
const wedgeInput = ref(null)
const wedgeBuffer = ref('')
let resetTimer = null
onMounted(focusWedge)
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusWedge() {
wedgeInput.value?.focus()
}
function onWedgeEnter() {
const scanned = wedgeBuffer.value.trim()
wedgeBuffer.value = ''
if (!scanned) return
if (step.value === 'item') lookupItem(scanned)
else if (step.value === 'badge') acceptBadge(scanned)
}
async function lookupItem(itemcode) {
error.value = ''
if (!itemcode) return
try {
const response = await printedpartsApi.kioskItem(itemcode.trim())
item.value = response.data.data
step.value = 'badge'
manualEntry.value = false
manualCode.value = ''
} catch (lookupError) {
error.value = lookupError.response?.data?.data?.error?.message ||
'No part matches that barcode'
}
focusWedge()
}
function acceptBadge(value) {
error.value = ''
const scanned = (value || '').trim()
if (!scanned) return
badge.value = scanned
manualBadge.value = ''
step.value = 'quantity'
focusWedge()
}
async function submitTake() {
submitting.value = true
error.value = ''
try {
const response = await printedpartsApi.kioskTake({
itemcode: item.value.itemcode,
badge: badge.value,
quantity: parseInt(quantity.value, 10)
})
doneMessage.value = response.data.message
step.value = 'done'
resetTimer = setTimeout(reset, 4000)
} catch (takeError) {
error.value = takeError.response?.data?.data?.error?.message ||
'Could not complete - see the parts team'
if (takeError.response?.status === 422) {
// badge problem: go back a step so the next scan retries cleanly
step.value = 'badge'
}
} finally {
submitting.value = false
}
}
function reset() {
clearTimeout(resetTimer)
step.value = 'item'
item.value = null
badge.value = ''
quantity.value = ''
error.value = ''
doneMessage.value = ''
focusWedge()
}
</script>
<style scoped>
.parts-kiosk {
min-height: 100vh;
background: var(--bg);
color: var(--text);
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.kiosk-header {
width: 100%;
max-width: 40rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.wedge-input {
position: absolute;
opacity: 0;
height: 1px;
width: 1px;
}
.kiosk-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
}
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
.kiosk-hint { color: var(--text-light); }
.kiosk-error {
background: var(--danger);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
}
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
.item-card {
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 1rem 1.5rem;
width: 100%;
}
.item-photo {
width: 5rem;
height: 5rem;
object-fit: cover;
border-radius: 0.4rem;
}
.quantity-display {
font-size: 3rem;
font-weight: 700;
min-width: 8rem;
text-align: center;
border-bottom: 3px solid var(--primary);
}
.take-button {
font-size: 1.5rem;
padding: 0.9rem 3.5rem;
}
.manual-row { display: flex; gap: 0.6rem; }
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="item">
<div class="hero-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
:alt="item.itemname" class="hero-image" />
<div class="hero-content">
<h2 class="hero-title">{{ item.itemname }}</h2>
<div class="hero-meta">
<span class="badge badge-secondary">{{ item.itemcode }}</span>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }} on hand
</span>
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
</div>
<div class="hero-details">
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
</div>
<div class="hero-actions">
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
Restock
</button>
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
Adjust
</button>
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
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>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Details</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Item code</span>
<span class="info-value">{{ item.itemcode }}</span>
</div>
<div class="info-row">
<span class="info-label">Bin location</span>
<span class="info-value">{{ item.binlocation || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Quantity on hand</span>
<span class="info-value">{{ item.quantityonhand }}</span>
</div>
<div class="info-row">
<span class="info-label">Low-stock threshold</span>
<span class="info-value">{{ item.lowstockthreshold }}</span>
</div>
<div class="info-row" v-if="item.printnotes">
<span class="info-label">Print notes</span>
<span class="info-value">{{ item.printnotes }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Recent transactions</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Qty</th>
<th>Who</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="transaction in item.recenttransactions"
:key="transaction.transactionid">
<td>{{ formatDate(transaction.transactiondate) }}</td>
<td>{{ transaction.transactiontype }}</td>
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
</td>
<td>{{ transaction.employeename || transaction.employeesso }}</td>
<td>{{ transaction.reason || '-' }}</td>
</tr>
<tr v-if="!item.recenttransactions?.length">
<td colspan="5" class="empty-state">No transactions yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="audit-footer">
Created {{ formatDate(item.createddate) }} -
Modified {{ formatDate(item.modifieddate) }}
</div>
</template>
<div v-else class="card">Item not found</div>
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
<div class="form-group">
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
</div>
<div v-if="ledgerMode === 'adjust'" class="form-group">
<label>Reason *</label>
<input v-model="ledgerReason" type="text" class="form-control"
placeholder="e.g., damaged parts scrapped, recount" />
</div>
<div class="form-group">
<label>Your badge / SSO *</label>
<input v-model="ledgerBadge" type="text" class="form-control"
placeholder="Scan badge or type SSO" />
</div>
<template #footer>
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
</button>
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import Modal from '../../components/Modal.vue'
const route = useRoute()
const item = ref(null)
const loading = ref(true)
onMounted(async () => {
try {
const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data
} catch (loadError) {
console.error('Error loading printed item:', loadError)
} finally {
loading.value = false
}
})
const ledgerOpen = ref(false)
const ledgerMode = ref('restock')
const ledgerQuantity = ref(null)
const ledgerReason = ref('')
const ledgerBadge = ref('')
const ledgerSaving = ref(false)
const ledgerError = ref('')
function openLedger(mode) {
ledgerMode.value = mode
ledgerQuantity.value = null
ledgerReason.value = ''
ledgerBadge.value = ''
ledgerError.value = ''
ledgerOpen.value = true
}
async function submitLedger() {
ledgerSaving.value = true
ledgerError.value = ''
try {
if (ledgerMode.value === 'restock') {
await printedpartsApi.restock(item.value.printeditemid, {
quantity: ledgerQuantity.value, badge: ledgerBadge.value
})
} else {
await printedpartsApi.adjust(item.value.printeditemid, {
quantitychange: ledgerQuantity.value,
reason: ledgerReason.value,
badge: ledgerBadge.value
})
}
ledgerOpen.value = false
const response = await printedpartsApi.get(item.value.printeditemid)
item.value = response.data.data
} catch (submitError) {
ledgerError.value =
submitError.response?.data?.data?.error?.message ||
submitError.response?.data?.error?.message || 'Submit failed'
} finally {
ledgerSaving.value = false
}
}
function formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
</script>
<style scoped>
.hero-actions { margin-top: 0.75rem; }
.qty-out { color: var(--danger); }
.qty-in { color: var(--success); }
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
</div>
<div class="card form-card">
<div v-if="error" class="error-message">{{ error }}</div>
<form @submit.prevent="save">
<div class="form-row">
<div class="form-group">
<label>Name *</label>
<input v-model="form.itemname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Bin location</label>
<input v-model="form.binlocation" type="text" class="form-control"
placeholder="e.g., Bin A3" />
</div>
</div>
<div class="form-group">
<label>Description</label>
<input v-model="form.itemdescription" type="text" class="form-control"
maxlength="500" placeholder="Brief description shown on the storefront" />
</div>
<div class="form-row">
<div class="form-group">
<label>Low-stock threshold</label>
<input v-model.number="form.lowstockthreshold" type="number" min="0"
class="form-control" />
</div>
<div v-if="isEdit" class="form-group">
<label>Item code</label>
<input :value="itemcode" type="text" class="form-control" disabled />
</div>
</div>
<div class="form-group">
<label>Print notes</label>
<textarea v-model="form.printnotes" class="form-control" rows="3"
placeholder="Material, print time, slicer file path"></textarea>
</div>
<div v-if="isEdit" class="form-group">
<label>Photo</label>
<div class="image-row">
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
<input type="file" accept="image/*" @change="onImagePicked" />
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
@click="removeImage">Remove photo</button>
</div>
</div>
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const cancelTarget = computed(() =>
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
const form = ref({
itemname: '',
itemdescription: '',
lowstockthreshold: null,
binlocation: '',
printnotes: ''
})
const itemcode = ref('')
const imageurl = ref(null)
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (!isEdit.value) return
try {
const response = await printedpartsApi.get(route.params.id)
const item = response.data.data
for (const key of Object.keys(form.value)) {
form.value[key] = item[key]
}
itemcode.value = item.itemcode
imageurl.value = item.imageurl
} catch (loadError) {
error.value = 'Could not load the item'
console.error(loadError)
}
})
async function save() {
saving.value = true
error.value = ''
try {
const payload = { ...form.value }
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
delete payload.lowstockthreshold
}
let printeditemid
if (isEdit.value) {
await printedpartsApi.update(route.params.id, payload)
printeditemid = route.params.id
} else {
const response = await printedpartsApi.create(payload)
printeditemid = response.data.data.printeditemid
}
router.push(`/printedparts/${printeditemid}`)
} catch (saveError) {
error.value = saveError.response?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
async function onImagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
try {
const response = await printedpartsApi.uploadImage(route.params.id, file)
imageurl.value = response.data.data.imageurl
} catch (uploadError) {
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
}
}
async function removeImage() {
try {
await printedpartsApi.deleteImage(route.params.id)
imageurl.value = null
} catch (deleteError) {
error.value = 'Could not remove the image'
console.error(deleteError)
}
}
</script>
<style scoped>
.image-row {
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview {
width: 6rem;
height: 6rem;
object-fit: cover;
border-radius: 0.35rem;
border: 1px solid var(--border);
}
.form-hint { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
<div class="header-actions">
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
Print Labels
</router-link>
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
</div>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search code, name, description, bin..."
@input="debouncedSearch"
/>
<label class="lowstock-filter">
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
Low stock only
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th></th>
<th>Code</th>
<th>Name</th>
<th>Quantity</th>
<th>Bin</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.printeditemid"
class="clickable-row"
@click="$router.push(`/printedparts/${item.printeditemid}`)"
>
<td class="thumb-cell">
<img
v-if="item.imageurl"
:src="withBase(item.imageurl)"
:alt="item.itemname"
class="item-thumb"
/>
</td>
<td>{{ item.itemcode || '-' }}</td>
<td>{{ item.itemname }}</td>
<td>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }}
</span>
</td>
<td>{{ item.binlocation || '-' }}</td>
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" class="empty-state">No printed parts found</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:total-pages="totalPages"
@change="setPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printedpartsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { withBase } from '../../utils/basePath'
const items = ref([])
const loading = ref(true)
const lowstockOnly = ref(false)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (lowstockOnly.value) params.lowstock = 'true'
const response = await printedpartsApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading printed parts:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => setSearch(search.value), 300)
}
</script>
<style scoped>
.item-thumb {
width: 2.2rem;
height: 2.2rem;
object-fit: cover;
border-radius: 0.25rem;
}
.thumb-cell { width: 3rem; }
.truncate-cell {
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions { display: flex; gap: 0.5rem; }
.lowstock-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text-light);
cursor: pointer;
}
</style>

View File

@@ -1,149 +0,0 @@
<template>
<div class="detail-page" v-if="item">
<div class="hero-card">
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Printedparts' }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/printedparts/${itemId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-details">
<div class="detail-item" v-if="item.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ item.assetnumber }}</span>
</div>
<div class="detail-item" v-if="item.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ item.serialnumber }}</span>
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Printedparts Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Example Field</span>
<span class="info-value">{{ item.examplefield || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ item.assetnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ item.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/printedparts/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading...</div>
</div>
<div v-else class="error-container">
<p>Record not found</p>
<router-link to="/printedparts" class="btn btn-secondary">Back to Printedparts</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
const printedpartsApi = {
list(params = {}) { return api.get('/printedparts', { params }) },
get(itemId) { return api.get(`/printedparts/${itemId}`) },
create(data) { return api.post('/printedparts', data) },
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const itemId = route.params.id
const item = ref(null)
const loading = ref(true)
onMounted(loadItem)
async function loadItem() {
loading.value = true
try {
const response = await printedpartsApi.get(itemId)
item.value = response.data.data
} catch (error) {
console.error('Error loading printedparts:', error)
item.value = null
} finally {
loading.value = false
}
}
async function confirmDelete() {
if (confirm('Delete this record?')) {
try {
await printedpartsApi.remove(itemId)
router.push('/printedparts')
} catch (error) {
console.error('Error deleting printedparts:', error)
}
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
</style>

View File

@@ -1,228 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Printedparts' : 'Add Printedparts' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<fieldset>
<legend>Printedparts Details</legend>
<div class="form-row">
<div class="form-group">
<label for="examplefield">Example Field</label>
<input
id="examplefield"
v-model="form.examplefield"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
const printedpartsApi = {
list(params = {}) { return api.get('/printedparts', { params }) },
get(itemId) { return api.get(`/printedparts/${itemId}`) },
create(data) { return api.post('/printedparts', data) },
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const itemId = route.params.id
const isEdit = computed(() => !!itemId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
examplefield: ''
})
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (isEdit.value) {
await loadItem()
}
})
async function loadItem() {
try {
const response = await printedpartsApi.get(itemId)
const data = response.data.data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.examplefield = data.examplefield || ''
} catch (loadError) {
console.error('Error loading printedparts:', loadError)
error.value = 'Failed to load record'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
examplefield: form.value.examplefield || null
}
let redirectId = itemId
if (isEdit.value) {
await printedpartsApi.update(itemId, payload)
} else {
const response = await printedpartsApi.create(payload)
redirectId = response.data.data?.assetid
}
router.push(redirectId ? `/printedparts/${redirectId}` : '/printedparts')
} catch (submitError) {
console.error('Error saving printedparts:', submitError)
error.value = 'Failed to save record'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/printedparts/${itemId}`)
} else {
router.push('/printedparts')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,142 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Printedparts</h2>
<router-link to="/printedparts/new" class="btn btn-primary">Add Printedparts</router-link>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Example Field</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.assetid">
<td>{{ item.assetnumber || '-' }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.examplefield || '-' }}</td>
<td class="actions">
<router-link
:to="`/printedparts/${item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No printedparts records found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
const printedpartsApi = {
list(params = {}) { return api.get('/printedparts', { params }) },
get(itemId) { return api.get(`/printedparts/${itemId}`) },
create(data) { return api.post('/printedparts', data) },
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
}
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
const response = await printedpartsApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading printedparts:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadItems()
}, 300)
}
function goToPage(target) {
if (target >= 1 && target <= totalPages.value) {
page.value = target
loadItems()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadItems()
}
</script>
<style scoped>
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
</style>

View File

@@ -41,3 +41,19 @@ pytest plugins/printedparts/tests/
- `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules
## Why the kiosk take endpoint is unauthenticated
`POST /api/printedparts/kiosk/take` is the product's first open WRITE (every
other kiosk endpoint is a read). Accepted deliberately, against the criteria
in docs/proposals/printedparts-plugin.md:
1. Decrement-only: it can reduce stock of an active item, nothing else.
2. Fully attributed: it refuses to act without a badge that resolves under
the site policy; every action lands in the ledger with SSO + name + time.
3. Bounded blast radius: worst case is stock counts driven low - visible in
the ledger and reversible with an adjust.
4. Physically rate-limited: it serves a touch screen on the shop floor;
nothing enumerable, nothing worth scraping.
Any future open-write endpoint must clear the same bar.

View File

@@ -1,18 +1,458 @@
"""Printedparts plugin API routes.
Stage 2 placeholder: the blueprint must import cleanly for plugin discovery
and migrations (the alembic env imports the models package, which pulls in
plugin.py and this module). Real endpoints land in the next stage.
Reads are open (jwt optional) like every list surface; mutations arrive in
later stages with permission gates. The kiosk endpoints (unauthenticated by
explicit decision - see the proposal) also land later.
"""
from flask import Blueprint
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from sqlalchemy import or_
from shopdb.api import success_response
from shopdb.api import (
db,
success_response,
error_response,
paginated_response,
ErrorCodes,
get_pagination_params,
paginate_query,
require_permission,
)
from ..models import PrintedItem
printedparts_bp = Blueprint('printedparts', __name__)
@printedparts_bp.route('/ping', methods=['GET'])
def ping():
"""Liveness probe for the lab: proves the blueprint is registered."""
return success_response({'plugin': 'printedparts', 'status': 'ok'})
@printedparts_bp.route('/items', methods=['GET'])
@jwt_required(optional=True)
def list_items():
"""List printed items, paginated; search + low-stock filter."""
page, per_page = get_pagination_params(request)
query = PrintedItem.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(PrintedItem.isactive == True)
if search := request.args.get('search'):
like = f'%{search}%'
query = query.filter(or_(
PrintedItem.itemcode.ilike(like),
PrintedItem.itemname.ilike(like),
PrintedItem.itemdescription.ilike(like),
PrintedItem.binlocation.ilike(like),
))
if request.args.get('lowstock', '').lower() == 'true':
query = query.filter(
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
query = query.order_by(PrintedItem.itemname)
items, total = paginate_query(query, page, per_page)
return paginated_response(
[item.to_dict() for item in items], page, per_page, total)
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
@jwt_required(optional=True)
def get_item(item_id: int):
"""Get one printed item with its recent transactions."""
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)
data = item.to_dict()
recent = (item.transactions
.order_by(db.desc('transactiondate'))
.limit(25).all())
data['recenttransactions'] = [t.to_dict() for t in recent]
return success_response(data)
# --- catalog mutations (stage 6 adds permission gates on top of jwt) --------
import glob
import os
from flask import current_app
from werkzeug.utils import secure_filename
from shopdb.api import Setting
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
IMAGE_URL_PREFIX = '/api/printedparts/image/'
EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold',
'binlocation', 'printnotes')
def _imagedir():
return os.path.join(current_app.instance_path, 'printedpartsimages')
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}'
@printedparts_bp.route('/items', methods=['POST'])
@jwt_required()
@require_permission('printedparts.create')
def create_item():
"""Create a printed item; the itemcode is minted from the row id."""
data = request.get_json() or {}
itemname = (data.get('itemname') or '').strip()
if not itemname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
threshold = data.get('lowstockthreshold')
if threshold is None:
threshold = int(Setting.get('printedparts_default_threshold') or 5)
item = PrintedItem(
itemname=itemname,
itemdescription=data.get('itemdescription'),
lowstockthreshold=threshold,
binlocation=data.get('binlocation'),
printnotes=data.get('printnotes'),
quantityonhand=0,
)
db.session.add(item)
db.session.flush() # assigns printeditemid
_mint_itemcode(item)
db.session.commit()
return success_response(item.to_dict(), message='Printed item created',
http_code=201)
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
@jwt_required()
@require_permission('printedparts.edit')
def update_item(item_id: int):
"""Update catalog fields. Quantity moves ONLY through the ledger."""
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)
data = request.get_json() or {}
if 'quantityonhand' in data:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'quantityonhand is ledger-managed; use restock or adjust')
for field in EDITABLE_FIELDS:
if field in data:
setattr(item, field, data[field])
db.session.commit()
return success_response(item.to_dict(), message='Printed item updated')
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printedparts.delete')
def delete_item(item_id: int):
"""Soft-retire an item; its ledger history stays."""
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 = False
db.session.commit()
return success_response(message='Printed item retired')
# --- item image: the models.py upload/serve/delete trio ---------------------
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
@jwt_required()
@require_permission('printedparts.edit')
def upload_item_image(item_id: int):
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
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 IMAGE_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
imagedir = _imagedir()
os.makedirs(imagedir, exist_ok=True)
for old in glob.glob(os.path.join(
imagedir, secure_filename(f'printeditem-{item_id}') + '.*')):
os.remove(old)
filename = secure_filename(f'printeditem-{item_id}{ext}')
upload.save(os.path.join(imagedir, filename))
item.imageurl = f'{IMAGE_URL_PREFIX}{filename}'
db.session.commit()
return success_response(item.to_dict(), message='Item image uploaded')
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
def serve_item_image(filename):
"""Serve an uploaded item image (public - kiosk and list read it)."""
from flask import send_from_directory
return send_from_directory(_imagedir(), filename)
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
@jwt_required()
@require_permission('printedparts.delete')
def delete_item_image(item_id: int):
"""Clear an item image; delete the file only if this plugin owns it."""
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)
url = item.imageurl or ''
if url.startswith(IMAGE_URL_PREFIX):
filename = secure_filename(url[len(IMAGE_URL_PREFIX):])
path = os.path.join(_imagedir(), filename)
if os.path.exists(path):
os.remove(path)
item.imageurl = None
db.session.commit()
return success_response(item.to_dict(), message='Item image removed')
# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock)
from ..models import PrintedItemTransaction
from ..services.badges import BadgeError, resolve_badge
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None):
"""Append a ledger row and move the cached quantity in ONE commit.
The single-commit invariant is what keeps quantityonhand equal to the
ledger sum; every write path must go through here.
"""
item.quantityonhand += quantitychange
db.session.add(PrintedItemTransaction(
printeditemid=item.printeditemid,
transactiontype=transactiontype,
quantitychange=quantitychange,
employeesso=sso,
employeename=name,
reason=reason,
))
db.session.commit()
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
@jwt_required()
@require_permission('printedparts.restock')
def restock_item(item_id: int):
"""Add freshly printed stock. Body: {quantity, badge}."""
item = db.session.get(PrintedItem, item_id)
if not item or not item.isactive:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
data = request.get_json() or {}
quantity = data.get('quantity')
if not isinstance(quantity, int) or quantity < 1:
return error_response(ErrorCodes.VALIDATION_ERROR,
'quantity must be a positive integer')
try:
sso, name = resolve_badge(data.get('badge'))
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
http_code=422)
_ledger_write(item, 'restock', quantity, sso, name)
return success_response(item.to_dict(), message='Stock added')
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
@jwt_required()
@require_permission('printedparts.restock')
def adjust_item(item_id: int):
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
item = db.session.get(PrintedItem, item_id)
if not item or not item.isactive:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
data = request.get_json() or {}
quantitychange = data.get('quantitychange')
if not isinstance(quantitychange, int) or quantitychange == 0:
return error_response(ErrorCodes.VALIDATION_ERROR,
'quantitychange must be a non-zero integer')
reason = (data.get('reason') or '').strip()
if not reason:
return error_response(ErrorCodes.VALIDATION_ERROR,
'reason is required for an adjustment')
if item.quantityonhand + quantitychange < 0:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Adjustment would drive stock below zero '
f'(on hand: {item.quantityonhand})')
try:
sso, name = resolve_badge(data.get('badge'))
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
http_code=422)
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
return success_response(item.to_dict(), message='Stock adjusted')
# --- kiosk: UNauthenticated by decision record --------------------------------
# The take endpoint is the product's first open WRITE. The proposal's decision
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
# 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.
@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()
if not item:
return error_response(ErrorCodes.NOT_FOUND,
'No part matches that barcode', http_code=404)
return success_response(item.to_dict())
@printedparts_bp.route('/kiosk/take', methods=['POST'])
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()
if not item:
return error_response(ErrorCodes.NOT_FOUND,
'No part matches that barcode', http_code=404)
quantity = data.get('quantity')
if not isinstance(quantity, int) or quantity < 1:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Enter how many you are taking')
if quantity > item.quantityonhand:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Only {item.quantityonhand} on hand - take fewer or see the '
f'parts team')
try:
sso, name = resolve_badge(data.get('badge'))
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
http_code=422)
_ledger_write(item, 'take', -quantity, sso, name)
return success_response(item.to_dict(),
message=f'Took {quantity}, {item.quantityonhand} left')
# --- reports (merged into GET /api/reports while the plugin is enabled) ------
import csv
import io
from flask import Response
from sqlalchemy import func
def _csv_response(rows, columns, filename):
"""CSV download; local helper because generate_csv is not on the
contract surface (shopdb.api)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in rows:
writer.writerow([row.get(column, '') for column in columns])
return Response(
output.getvalue(), mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename={filename}'})
@printedparts_bp.route('/reports/stock', methods=['GET'])
@jwt_required(optional=True)
def report_stock():
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
ledgerdelta should always be 0; anything else means a write path
bypassed the single-commit rule and needs finding.
"""
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
# string and breaks the delta arithmetic's type.
ledger = {itemid: int(total) for itemid, total in
db.session.query(
PrintedItemTransaction.printeditemid,
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
.group_by(PrintedItemTransaction.printeditemid).all()}
rows = []
for item in PrintedItem.query.filter_by(isactive=True).order_by(
PrintedItem.itemname).all():
rows.append({
'itemcode': item.itemcode,
'itemname': item.itemname,
'binlocation': item.binlocation or '',
'quantityonhand': item.quantityonhand,
'lowstockthreshold': item.lowstockthreshold,
'islowstock': item.islowstock,
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
})
columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand',
'lowstockthreshold', 'islowstock', 'ledgerdelta']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-stock.csv')
return success_response({'columns': columns, 'rows': rows})
@printedparts_bp.route('/reports/consumption', methods=['GET'])
@jwt_required(optional=True)
def report_consumption():
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItem.itemcode,
PrintedItem.itemname,
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.join(PrintedItemTransaction,
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
'quantitytaken': int(taken)}
for code, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-consumption.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})
@printedparts_bp.route('/reports/by-person', methods=['GET'])
@jwt_required(optional=True)
def report_by_person():
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItemTransaction.employeesso,
func.max(PrintedItemTransaction.employeename),
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItemTransaction.employeesso)
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
'quantitytaken': int(taken)}
for sso, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-by-person.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})

View File

@@ -51,6 +51,53 @@ class PrintedpartsPlugin(BasePlugin):
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
def get_permissions(self) -> List:
"""RBAC permissions this plugin owns (seeded on install/enable)."""
return [
('printedparts.view', 'View 3D printed parts', 'printedparts'),
('printedparts.create', 'Create printed parts', 'printedparts'),
('printedparts.edit', 'Edit printed parts', 'printedparts'),
('printedparts.delete', 'Retire printed parts', 'printedparts'),
('printedparts.restock', 'Restock and adjust stock counts',
'printedparts'),
]
def get_reports(self) -> List[dict]:
return [
{
'id': 'printedparts-stock',
'name': '3D Parts Stock',
'description': 'Stock levels with low-stock flags and the '
'cache-vs-ledger reconcile check',
'category': 'inventory',
'endpoint': '/api/printedparts/reports/stock',
},
{
'id': 'printedparts-consumption',
'name': '3D Parts Consumption',
'description': 'Takes per item over a date range',
'category': 'usage',
'endpoint': '/api/printedparts/reports/consumption',
},
{
'id': 'printedparts-by-person',
'name': '3D Parts by Person',
'description': 'Takes grouped by employee',
'category': 'usage',
'endpoint': '/api/printedparts/reports/by-person',
},
]
def get_navigation_items(self) -> List[dict]:
return [
{
'name': '3D Parts',
'icon': 'box',
'route': '/printedparts',
'position': 46,
},
]
def on_install(self, app: Flask) -> None:
with app.app_context():
self._seed_settings()

View File

@@ -0,0 +1 @@
"""Printedparts plugin services."""

View File

@@ -0,0 +1,69 @@
"""Badge resolution for the printedparts plugin.
Same input contract as the USB plugin (deliberately copied, not imported -
cross-plugin imports break the shopdb.api-only contract):
- all digits -> an SSO typed or scanned directly
- 0<digits>BZ -> a physical badge wrapping a PayNo (keyboard-wedge
scanners emit this shape)
- anything else -> unresolvable
Names come from the employees plugin's self-hosted directory, looked up by
SSO. The directory carries no PayNo column, so PayNo badges resolve only when
the wrapped digits are themselves the SSO (true at sites whose badges encode
the SSO); otherwise they fall to the unknown-badge policy.
Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge
with no directory match; 'allow' records the SSO with an empty name.
"""
import re
from shopdb.api import Setting
_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
class BadgeError(ValueError):
"""Raised when a badge cannot be accepted under the site policy."""
def _directory_name(sso):
"""Best-effort display name from the employees plugin directory."""
try:
from plugins.employees.models import DirectoryEmployee
from shopdb.api import db
if sso and str(sso).isdigit():
employee = db.session.get(DirectoryEmployee, int(sso))
if employee:
return f'{employee.firstname} {employee.lastname}'.strip()
except Exception:
pass
return None
def resolve_badge(badge):
"""Return (sso, name) for a scanned badge, enforcing the site policy.
Raises BadgeError with a kiosk-displayable message when the badge shape is
unrecognized or the policy denies an unmatched badge.
"""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
if badge.isdigit():
sso = badge
else:
match = _PAYNO_BADGE.match(badge)
if not match:
raise BadgeError('Unrecognized badge format')
sso = match.group(1)
name = _directory_name(sso)
if name is None:
policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
if policy != 'allow':
raise BadgeError('Badge not recognized - see the parts team')
return sso, ''
return sso, name

View File

@@ -0,0 +1,168 @@
"""Printedparts ledger + badge tests.
The invariants that make the plugin trustworthy: itemcode minting, the
single-commit cache==ledger rule, the below-zero guard, quantity edits
forced through the ledger, and the badge contract (SSO digits, PayNo
wrap, unknown-badge policy).
"""
import pytest
from shopdb.api import db
from shopdb.core.models import Setting
from plugins.printedparts.models import PrintedItem, PrintedItemTransaction
@pytest.fixture
def item(app, db):
with app.app_context():
row = PrintedItem(itemcode='3DP-9001', itemname='Test clip',
quantityonhand=0, lowstockthreshold=5)
db.session.add(row)
db.session.commit()
yield row.printeditemid
@pytest.fixture
def directory_employee(app):
with app.app_context():
from plugins.employees.models import DirectoryEmployee
if not db.session.get(DirectoryEmployee, 502000001):
db.session.add(DirectoryEmployee(
sso=502000001, firstname='Pat', lastname='Printer'))
db.session.commit()
return '502000001'
def test_create_mints_itemcode(client, auth_headers):
response = client.post('/api/printedparts/items', json={'itemname': 'Bracket'},
headers=auth_headers)
assert response.status_code == 201
data = response.get_json()['data']
assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}"
assert data['quantityonhand'] == 0
def test_update_refuses_quantity(client, auth_headers, item):
response = client.put(f'/api/printedparts/items/{item}',
json={'quantityonhand': 50}, headers=auth_headers)
assert response.status_code == 400
def test_restock_writes_ledger_and_cache(client, auth_headers, app, item,
directory_employee):
response = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 10, 'badge': directory_employee},
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['quantityonhand'] == 10
with app.app_context():
rows = PrintedItemTransaction.query.filter_by(printeditemid=item).all()
assert len(rows) == 1
assert rows[0].transactiontype == 'restock'
assert rows[0].quantitychange == 10
assert rows[0].employeename == 'Pat Printer'
cached = db.session.get(PrintedItem, item).quantityonhand
assert cached == sum(r.quantitychange for r in rows)
def test_payno_badge_shape_resolves(client, auth_headers, item,
directory_employee):
response = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1,
'badge': f'0{directory_employee}BZ'},
headers=auth_headers)
assert response.status_code == 200
def test_adjust_requires_reason_and_floors_at_zero(client, auth_headers, item,
directory_employee):
no_reason = client.post(f'/api/printedparts/items/{item}/adjust',
json={'quantitychange': -1,
'badge': directory_employee},
headers=auth_headers)
assert no_reason.status_code == 400
below_zero = client.post(f'/api/printedparts/items/{item}/adjust',
json={'quantitychange': -1, 'reason': 'test',
'badge': directory_employee},
headers=auth_headers)
assert below_zero.status_code == 400
def test_unknown_badge_denied_then_allowed_by_policy(client, auth_headers, app,
item):
denied = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '999999999'},
headers=auth_headers)
assert denied.status_code == 422
with app.app_context():
Setting.set('printedparts_unknown_badge', 'allow',
valuetype='string', category='printedparts')
db.session.commit()
try:
allowed = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '999999999'},
headers=auth_headers)
assert allowed.status_code == 200
assert allowed.get_json()['data']['quantityonhand'] == 1
finally:
with app.app_context():
Setting.set('printedparts_unknown_badge', 'deny',
valuetype='string', category='printedparts')
db.session.commit()
def test_anonymous_cannot_mutate(client, item):
assert client.post('/api/printedparts/items',
json={'itemname': 'X'}).status_code == 401
assert client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '1'}).status_code == 401
def test_member_without_permission_gets_403(client, member_headers, item):
"""Authentication alone is not authorization: a role-less user is denied."""
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
headers=member_headers).status_code == 403
assert client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '1'},
headers=member_headers).status_code == 403
def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
directory_employee):
"""The kiosk endpoint needs no auth but only ever decrements stock."""
itemcode = '3DP-9001'
stocked = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 5, 'badge': directory_employee},
headers=auth_headers)
assert stocked.status_code == 200
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}')
assert lookup.status_code == 200
take = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2})
assert take.status_code == 200, take.get_json()
assert take.get_json()['data']['quantityonhand'] == 3
too_many = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99})
assert too_many.status_code == 400
unknown = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1})
assert unknown.status_code == 422
with app.app_context():
rows = PrintedItemTransaction.query.filter_by(
printeditemid=item, transactiontype='take').all()
assert len(rows) == 1
assert rows[0].quantitychange == -2
assert rows[0].employeename == 'Pat Printer'
cached = db.session.get(PrintedItem, item).quantityonhand
ledgersum = sum(r.quantitychange for r in
PrintedItemTransaction.query.filter_by(
printeditemid=item).all())
assert cached == ledgersum