2 Commits

Author SHA1 Message Date
cproudlock
eab225e1e6 printedparts stage 14: retire/restore in the UI, dashless item codes
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
Retire button with confirmation on the detail page (item leaves the
storefront and the kiosk rejects its code; ledger history and label
survive), Restore on retired items, and an Include-retired list toggle
with a badge. Restore is its own permission-gated POST - the generic
update still cannot flip isactive. New codes mint as WJRP0042 style
without the dash; existing codes are immutable bin labels and keep
their form.
2026-07-17 08:35:31 -04:00
cproudlock
a8a6baf979 printedparts stage 13: pick alert recipients from shopdb users
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Contract 0.13.0 puts the User model on the plugin surface. The
settings page gains a checkbox picker over the user list; selected
users receive low-stock alerts at their account email, merged and
deduped with the free-text address list, inactive accounts skipped,
site alert_recipients still the fallback when both are empty.
2026-07-17 08:30:04 -04:00
13 changed files with 225 additions and 15 deletions

View File

@@ -43,7 +43,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
### Active state
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
- `__contract_version__` at 0.12.0 (0.12.0 adds the mailer to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- `__contract_version__` at 0.13.0 (0.12.0 added the mailer, 0.13.0 the User model, to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM).

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.12.0'
__contract_version__ = '0.13.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -479,6 +479,8 @@ What `shopdb.api` exposes:
- Import mode: `apply_import_timestamps`, `import_mode_active`,
`parse_import_datetime`
- Legacy employee directory: `employee_connection`
- `User` (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

View File

@@ -320,6 +320,33 @@ it. The page is ordinary:
3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` -
the card appears in the rail's catalog while the plugin is enabled.
## Stage 13 (extension) - alert recipients picked from shopdb users
Free-text emails rot; user accounts do not. Another contract addition:
`User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the
docs-drift guard again).
1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded
beside the others.
2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's
account email, merge with the free-text list, dedupe order-preserving;
empty result still falls back to the site alert_recipients.
3. Settings page: checkbox picker over `usersApi.list()` (the page is
admin-only, matching the endpoint), saving joined ids.
4. Test: active user's email + free-text merge deduped, inactive user
skipped (`test_alert_recipients_merge_users_and_freetext`).
## Stage 14 (extension) - retire/restore in the UI, dashless codes
Field feedback stage: the soft-delete endpoint existed with no button, and
the site wanted `WJRP0042`, not `WJRP-0042`.
1. Detail gains Retire (confirm dialog; item leaves the storefront and the
kiosk 404s its code, history and label intact) and Restore; the list
gains an Include-retired toggle (`?active=false`) with a Retired badge.
Restore is its own POST gated by printedparts.delete - PUT deliberately
cannot flip isactive.
2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their
codes - itemcode is an immutable label once printed on a bin.
---
## Where each pattern lives (cheat sheet)

View File

@@ -1,6 +1,6 @@
# Roadmap
shopdb-flask is at `__contract_version__ = '0.12.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
## Phase status

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -13,7 +13,7 @@
<input v-model="values.printedparts_code_prefix" type="text"
class="form-control" maxlength="8" />
<p class="field-hint">
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}-0042.
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
Changing it does not rename existing items.
</p>
</div>
@@ -34,7 +34,23 @@
</div>
<div class="form-group">
<label>Low-stock alert recipients</label>
<label>Alert shopdb users</label>
<div class="user-picker">
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
<input type="checkbox" :value="String(candidate.userid)"
v-model="selectedUserids" />
<span>{{ candidate.username }}</span>
<span class="user-email">{{ candidate.email }}</span>
</label>
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
</div>
<p class="field-hint">
Selected users receive low-stock alerts at their account email.
</p>
</div>
<div class="form-group">
<label>Additional alert emails</label>
<input v-model="values.printedparts_alert_email" type="text"
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
<p class="field-hint">
@@ -53,21 +69,25 @@
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { settingsApi, usersApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_default_threshold',
'printedparts_unknown_badge',
'printedparts_alert_email'
'printedparts_alert_email',
'printedparts_alert_userids'
]
const values = ref({
printedparts_code_prefix: '3DP',
printedparts_default_threshold: 5,
printedparts_unknown_badge: 'deny',
printedparts_alert_email: ''
printedparts_alert_email: '',
printedparts_alert_userids: ''
})
const users = ref([])
const selectedUserids = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
@@ -81,6 +101,11 @@ onMounted(async () => {
}
values.value.printedparts_default_threshold =
parseInt(values.value.printedparts_default_threshold, 10) || 0
selectedUserids.value = (values.value.printedparts_alert_userids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const usersResponse = await usersApi.list()
users.value = (usersResponse.data.data || []).filter(
candidate => candidate.isactive && candidate.email)
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
@@ -92,6 +117,7 @@ async function save() {
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] ?? ''))
}
@@ -106,4 +132,21 @@ async function save() {
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.user-picker {
max-height: 12rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0.35rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.user-email { color: var(--text-light); font-size: 0.85rem; }
</style>

View File

@@ -91,7 +91,7 @@ def _imagedir():
def _mint_itemcode(item):
"""Set itemcode from the configured prefix + the flushed row id."""
prefix = Setting.get('printedparts_code_prefix') or '3DP'
item.itemcode = f'{prefix}-{item.printeditemid:04d}'
item.itemcode = f'{prefix}{item.printeditemid:04d}'
@printedparts_bp.route('/items', methods=['POST'])
@@ -159,6 +159,20 @@ def delete_item(item_id: int):
return success_response(message='Printed item retired')
@printedparts_bp.route('/items/<int:item_id>/restore', methods=['POST'])
@jwt_required()
@require_permission('printedparts.delete')
def restore_item(item_id: int):
"""Bring a retired item back; code, photo, and history are intact."""
item = db.session.get(PrintedItem, item_id)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
item.isactive = True
db.session.commit()
return success_response(item.to_dict(), message='Printed item restored')
# --- item image: the models.py upload/serve/delete trio ---------------------
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
@@ -250,6 +264,27 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None)
_send_lowstock_alert(item)
def _alert_recipients():
"""Merge selected shopdb users' account emails with the free-text list.
Empty result means fall back to the site-wide alert_recipients."""
from shopdb.api import User
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.
@@ -265,10 +300,9 @@ def _send_lowstock_alert(item):
f'<p>Bin: {item.binlocation or "-"}</p>'
f'<p>Time to print more.</p>')
try:
recipients = (Setting.get('printedparts_alert_email') or '').strip()
recipients = _alert_recipients()
if recipients:
send_email([address.strip() for address in recipients.split(',')
if address.strip()], subject, html)
send_email(recipients, subject, html)
else:
send_alert(subject, html)
except Exception:

View File

@@ -133,6 +133,9 @@ class PrintedpartsPlugin(BasePlugin):
('printedparts_alert_email', '', 'string',
'Comma-separated low-stock alert recipients; empty uses the '
'site alert_recipients'),
('printedparts_alert_userids', '', 'string',
'Comma-separated shopdb user ids whose account emails receive '
'low-stock alerts'),
]
for key, value, valuetype, description in defaults:
if Setting.get(key) is None:

View File

@@ -36,7 +36,7 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
__contract_version__ = '0.12.0'
__contract_version__ = '0.13.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -44,6 +44,7 @@ from shopdb.core.models import (
OperatingSystem,
AssetRelationship,
RelationshipType,
User,
)
# Response + pagination helpers for plugin API blueprints
@@ -269,6 +270,7 @@ __all__ = [
'employee_connection',
'send_email',
'send_alert',
'User',
# CMMC USB check-in/out database
'cmmc_usb_connection',
]

View File

@@ -39,7 +39,7 @@ def test_create_mints_itemcode(client, auth_headers):
headers=auth_headers)
assert response.status_code == 201
data = response.get_json()['data']
assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}"
assert data['itemcode'] == f"3DP{data['printeditemid']:04d}"
assert data['quantityonhand'] == 0
@@ -200,3 +200,63 @@ def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
restock(20) # 22: rearmed
assert take(18).status_code == 200 # 4: crosses again -> second alert
assert sent == ['3DP-9001', '3DP-9001']
def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
item, directory_employee,
monkeypatch):
"""Selected shopdb users' account emails merge with the free-text list,
deduped; inactive users are skipped."""
import shopdb.api as contract_surface
captured = {}
monkeypatch.setattr(contract_surface, 'send_email',
lambda to, subject, html, text=None:
captured.setdefault('to', to) or True)
with app.app_context():
from shopdb.api import User
from werkzeug.security import generate_password_hash
active = User(username='partslead', email='lead@site.test',
passwordhash=generate_password_hash('x'), isactive=True)
inactive = User(username='oldtimer', email='gone@site.test',
passwordhash=generate_password_hash('x'),
isactive=False)
db.session.add_all([active, inactive])
db.session.commit()
Setting.set('printedparts_alert_userids',
f'{active.userid},{inactive.userid}',
valuetype='string', category='printedparts')
Setting.set('printedparts_alert_email',
'extra@site.test, lead@site.test',
valuetype='string', category='printedparts')
db.session.commit()
client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 10, 'badge': directory_employee},
headers=auth_headers)
take = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001',
'badge': directory_employee, 'quantity': 6})
assert take.status_code == 200 # 4 on hand: crossed threshold 5
assert captured['to'] == ['lead@site.test', 'extra@site.test']
def test_retire_hides_and_restore_returns(client, auth_headers, item):
"""Retire drops the item from the default list and the kiosk; restore
brings it back with history intact."""
assert client.delete(f'/api/printedparts/items/{item}',
headers=auth_headers).status_code == 200
listed = client.get('/api/printedparts/items').get_json()['data']
assert all(row['printeditemid'] != item for row in listed)
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
assert kiosk.status_code == 404
including = client.get('/api/printedparts/items?active=false')
assert any(row['printeditemid'] == item
for row in including.get_json()['data'])
assert client.post(f'/api/printedparts/items/{item}/restore',
headers=auth_headers).status_code == 200
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200