Compare commits
4 Commits
lab-stage-
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eab225e1e6 | ||
|
|
a8a6baf979 | ||
|
|
427eb0de8c | ||
|
|
df918ed38f |
@@ -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.11.0 (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.11.0'
|
||||
__contract_version__ = '0.13.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -479,6 +479,11 @@ What `shopdb.api` exposes:
|
||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime`
|
||||
- Legacy employee directory: `employee_connection`
|
||||
- `User` (0.13.0) - the account model, e.g. resolving alert recipients'
|
||||
emails from selected user ids
|
||||
- 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
|
||||
|
||||
```python
|
||||
from shopdb.api import db, Asset, AssetType, success_response, paginate_query
|
||||
|
||||
@@ -286,6 +286,67 @@ Done means: a colleague can clone the repo, enable the plugin, print a bin
|
||||
label, and take a part at the kiosk with their badge - without asking you
|
||||
anything.
|
||||
|
||||
## Stage 11 (extension) - low-stock email alerts
|
||||
|
||||
Per-item thresholds already exist; alerting on them is a worked example of a
|
||||
CONTRACT ADDITION, because the mailer was not on the plugin surface:
|
||||
1. Export `send_email`/`send_alert` from `shopdb/api/__init__.py`, bump
|
||||
`__contract_version__` 0.11.0 -> 0.12.0, and update PLUGIN-HOOKS.md - the
|
||||
docs-drift guard test fails until the doc's version example matches.
|
||||
Manifest pins `core_version >=0.12.0` since the plugin now needs it.
|
||||
2. Fire the alert inside `_ledger_write` when a DECREMENT crosses the
|
||||
threshold (before > threshold >= after). Crossing, not being-below, is the
|
||||
natural debounce: one alert per depletion, restocking above rearms.
|
||||
Best-effort try/except AFTER the commit - mail failure must never fail
|
||||
the take.
|
||||
3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty
|
||||
falls back to the site's alert_recipients via `send_alert`. Seed the new
|
||||
setting in on_enable too (idempotent) so already-installed sites get it.
|
||||
4. Test with a monkeypatched sender: no alert above threshold, one on the
|
||||
crossing, no re-fire while below, rearm after restock (see
|
||||
`test_lowstock_alert_fires_on_crossing_only`).
|
||||
|
||||
## Stage 12 (extension) - the admin settings page
|
||||
|
||||
A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred
|
||||
it. The page is ordinary:
|
||||
1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four
|
||||
keys via `settingsApi.list({category: 'printedparts'})`, save each with
|
||||
`settingsApi.update(key, value)` (admin-gated server-side).
|
||||
2. Route in the PLUGIN's router file with path `settings/printedparts` +
|
||||
`requiresAuth, requiresAdmin, plugin` meta - the router shell
|
||||
automatically nests any `settings/...` path under the two-pane settings
|
||||
rail.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Where each pattern lives (cheat sheet)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
shopdb-flask is at `__contract_version__ = '0.11.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)
|
||||
|
||||
@@ -31,5 +31,11 @@ export default [
|
||||
name: 'printedparts-edit',
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printedparts',
|
||||
name: 'settings-printedparts',
|
||||
component: () => import('../../views/settings/PrintedPartsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -197,6 +202,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
152
frontend/src/views/settings/PrintedPartsSettings.vue
Normal file
152
frontend/src/views/settings/PrintedPartsSettings.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Item code prefix</label>
|
||||
<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.
|
||||
Changing it does not rename existing items.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Default low-stock threshold</label>
|
||||
<input v-model.number="values.printedparts_default_threshold"
|
||||
type="number" min="0" class="form-control" />
|
||||
<p class="field-hint">Seed value for new items; each item can override.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
<option value="deny">Deny - refuse badges with no directory match</option>
|
||||
<option value="allow">Allow - record the SSO with no name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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>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">
|
||||
Comma-separated. Empty uses the site-wide alert recipients
|
||||
(Settings > System > Email). Alerts fire once when an item
|
||||
crosses its threshold; restocking above re-arms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
printedparts_code_prefix: '3DP',
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: ''
|
||||
})
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'printedparts' })
|
||||
const rows = response.data.data || []
|
||||
for (const row of rows) {
|
||||
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
||||
}
|
||||
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)
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||
}
|
||||
message.value = 'Settings saved'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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'])
|
||||
@@ -228,8 +242,12 @@ 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.
|
||||
ledger sum; every write path must go through here. Fires the low-stock
|
||||
alert when this write CROSSES the item's threshold downward - crossing
|
||||
(not being below) is the natural debounce: one alert per depletion, and
|
||||
restocking above the threshold rearms it.
|
||||
"""
|
||||
quantitybefore = item.quantityonhand
|
||||
item.quantityonhand += quantitychange
|
||||
db.session.add(PrintedItemTransaction(
|
||||
printeditemid=item.printeditemid,
|
||||
@@ -240,6 +258,57 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None)
|
||||
reason=reason,
|
||||
))
|
||||
db.session.commit()
|
||||
if (quantitychange < 0
|
||||
and quantitybefore > item.lowstockthreshold
|
||||
and item.quantityonhand <= item.lowstockthreshold):
|
||||
_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
|
||||
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)
|
||||
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.
|
||||
|
||||
Recipients: Setting printedparts_alert_email (comma-separated), falling
|
||||
back to the site's alert_recipients. Never fails the transaction - the
|
||||
ledger write already committed."""
|
||||
from shopdb.api import send_email, send_alert
|
||||
subject = (f'Low stock: {item.itemname} ({item.itemcode}) - '
|
||||
f'{item.quantityonhand} left')
|
||||
html = (f'<p><strong>{item.itemname}</strong> ({item.itemcode}) is down '
|
||||
f'to <strong>{item.quantityonhand}</strong> '
|
||||
f'(threshold {item.lowstockthreshold}).</p>'
|
||||
f'<p>Bin: {item.binlocation or "-"}</p>'
|
||||
f'<p>Time to print more.</p>')
|
||||
try:
|
||||
recipients = _alert_recipients()
|
||||
if recipients:
|
||||
send_email(recipients, subject, html)
|
||||
else:
|
||||
send_alert(subject, html)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception(
|
||||
'Low-stock alert failed for %s', item.itemcode)
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"display_name": "3D Printed Parts",
|
||||
"author": "",
|
||||
"dependencies": ["employees"],
|
||||
"core_version": ">=0.11.0,<1.0.0",
|
||||
"core_version": ">=0.12.0,<1.0.0",
|
||||
"api_prefix": "/api/printedparts",
|
||||
"default_enabled": false
|
||||
}
|
||||
|
||||
@@ -62,6 +62,19 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'printedparts'),
|
||||
]
|
||||
|
||||
def get_settings_cards(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
'group': '3D Printed Parts',
|
||||
'to': '/settings/printedparts',
|
||||
'icon': 'box',
|
||||
'title': '3D Parts Settings',
|
||||
'description': 'Item code prefix, default threshold, kiosk '
|
||||
'badge policy, low-stock alert recipients',
|
||||
'position': 47,
|
||||
},
|
||||
]
|
||||
|
||||
def get_reports(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
@@ -103,6 +116,12 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
self._seed_settings()
|
||||
logger.info('Printedparts plugin installed')
|
||||
|
||||
def on_enable(self, app: Flask) -> None:
|
||||
# Idempotent re-seed so settings added in later versions reach sites
|
||||
# that installed earlier (enable runs on every upgrade cycle).
|
||||
with app.app_context():
|
||||
self._seed_settings()
|
||||
|
||||
def _seed_settings(self) -> None:
|
||||
defaults = [
|
||||
('printedparts_code_prefix', '3DP', 'string',
|
||||
@@ -111,6 +130,12 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'Default low-stock threshold for new items'),
|
||||
('printedparts_unknown_badge', 'deny', 'string',
|
||||
'Kiosk policy when a badge resolves to no employee: allow or deny'),
|
||||
('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'),
|
||||
]
|
||||
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.11.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,7 @@ from shopdb.core.models import (
|
||||
OperatingSystem,
|
||||
AssetRelationship,
|
||||
RelationshipType,
|
||||
User,
|
||||
)
|
||||
|
||||
# Response + pagination helpers for plugin API blueprints
|
||||
@@ -78,6 +79,7 @@ from shopdb.core.services.dualpath import (
|
||||
|
||||
# Legacy employee directory lookup (read-only) used by notifications
|
||||
from shopdb.utils.employee_db import employee_connection
|
||||
from shopdb.utils.mailer import send_email, send_alert
|
||||
|
||||
# CMMC USB check-in/out database (read-write) used by the usb plugin
|
||||
from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
|
||||
@@ -266,6 +268,9 @@ __all__ = [
|
||||
'parse_import_datetime',
|
||||
# Legacy employee directory
|
||||
'employee_connection',
|
||||
'send_email',
|
||||
'send_alert',
|
||||
'User',
|
||||
# CMMC USB check-in/out database
|
||||
'cmmc_usb_connection',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -166,3 +166,97 @@ def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
|
||||
PrintedItemTransaction.query.filter_by(
|
||||
printeditemid=item).all())
|
||||
assert cached == ledgersum
|
||||
|
||||
|
||||
def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
|
||||
directory_employee, monkeypatch):
|
||||
"""One alert when stock CROSSES the threshold downward; restocking above
|
||||
rearms it; staying below does not re-fire."""
|
||||
sent = []
|
||||
import plugins.printedparts.api.routes as printedparts_routes
|
||||
monkeypatch.setattr(
|
||||
printedparts_routes, '_send_lowstock_alert',
|
||||
lambda alerted_item: sent.append(alerted_item.itemcode))
|
||||
|
||||
def restock(quantity):
|
||||
return client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': quantity,
|
||||
'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
|
||||
def take(quantity):
|
||||
return client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee,
|
||||
'quantity': quantity})
|
||||
|
||||
restock(10) # 10 on hand, threshold 5
|
||||
assert take(3).status_code == 200 # 7: above threshold, no alert
|
||||
assert sent == []
|
||||
assert take(3).status_code == 200 # 4: CROSSES 5 -> one alert
|
||||
assert sent == ['3DP-9001']
|
||||
assert take(2).status_code == 200 # 2: still below, no re-fire
|
||||
assert sent == ['3DP-9001']
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user