Three defects, all found on printedparts_label_prefix, all one root cause: nothing in the framework knew that setting existed. The parts kiosk runs logged out. An unauthenticated read of a setting is limited to an allowlist, the key was not on it, so the kiosk got a 404 and fell back to no prefix. An admin previewing the same page while logged in saw the prefix, which is why it looked like it worked. The same setting also looked like it would not save. The row did not exist on a site that installed the plugin before the setting was added, so the first save created it - under the placeholder category the settings API uses for keys it does not recognise, where the plugin's settings page, which lists by category, could no longer see it. The value was in the database the whole time. And the row was missing in the first place because seeding ran from on_install / on_enable, which fire only on a state transition. Neither runs again on an upgrade, so a setting added in a later plugin version never reached a site that installed an earlier one. The comment claiming enable ran every upgrade cycle was simply wrong. A plugin now declares the settings it owns in get_settings_defaults(): key, default, type, category, description, and whether a logged-out page may read it. The framework seeds declared keys at install, at enable, and on every flask plugin upgrade-all; files a first-time write under the declared category; re-homes any row left in the placeholder category, value untouched; and answers an anonymous read for keys marked public. Core carries no list of any plugin's keys. Contract 0.16.0 (additive optional hook). printedparts and printers move to the hook and floor their core_version at 0.16.0. The dev database had two rows in the misfiled state (printedparts_alert_email, employee_db_host); the first repairs itself on the next upgrade pass.
190 lines
7.2 KiB
Python
190 lines
7.2 KiB
Python
"""Printedparts plugin main class.
|
|
|
|
3D-printed parts inventory + kiosk checkout. Quantity-based consumables:
|
|
one row is a KIND of part with a count, not an individually tracked asset,
|
|
so unlike most plugins this one seeds NO AssetType (ADR-001 assets are
|
|
one-row-per-physical-thing). See docs/proposals/printedparts-plugin.md.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Optional, Type
|
|
|
|
from flask import Flask, Blueprint
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
|
|
from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
|
|
from .api import printedparts_bp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PrintedpartsPlugin(BasePlugin):
|
|
"""3D-printed parts inventory + kiosk checkout."""
|
|
|
|
def __init__(self):
|
|
manifest_path = Path(__file__).parent / 'manifest.json'
|
|
with open(manifest_path) as f:
|
|
self._manifest = json.load(f)
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
return PluginMeta(
|
|
name=self._manifest['name'],
|
|
version=self._manifest['version'],
|
|
description=self._manifest['description'],
|
|
author=self._manifest.get('author', ''),
|
|
dependencies=self._manifest.get('dependencies', []),
|
|
core_version=self._manifest.get('core_version', '>=0.1.0'),
|
|
api_prefix=self._manifest.get('api_prefix'),
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
return printedparts_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
return [PrintedItem, PrintedItemTransaction, PrintedItemFile]
|
|
|
|
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_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 [
|
|
{
|
|
'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:
|
|
logger.info('Printedparts plugin installed')
|
|
|
|
def get_settings_defaults(self) -> List[dict]:
|
|
# The framework seeds these at install, at enable, and on every
|
|
# `flask plugin upgrade-all`, so a key added in a later version reaches
|
|
# a site that installed an earlier one.
|
|
return [
|
|
{
|
|
'key': 'printedparts_code_prefix',
|
|
'value': '3DP',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Prefix for generated item codes',
|
|
},
|
|
{
|
|
'key': 'printedparts_label_prefix',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
# Public: the parts kiosk runs logged out, so the anonymous
|
|
# read has to be allowed or the prefix never renders there.
|
|
'public': True,
|
|
'description': 'Leading text on the physical gage-lab labels, '
|
|
'shown at the kiosk before the number box so '
|
|
'operators type only the digits. Empty shows no '
|
|
'prefix. Site-specific: West Jefferson labels '
|
|
'read WJ',
|
|
},
|
|
{
|
|
'key': 'printedparts_default_threshold',
|
|
'value': '5',
|
|
'valuetype': 'integer',
|
|
'category': 'printedparts',
|
|
'description': 'Default low-stock threshold for new items',
|
|
},
|
|
{
|
|
'key': 'printedparts_unknown_badge',
|
|
'value': 'deny',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Kiosk policy when a badge resolves to no '
|
|
'employee: allow or deny',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_email',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Comma-separated low-stock alert recipients; '
|
|
'empty uses the site alert_recipients',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_userids',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Comma-separated shopdb user ids whose account '
|
|
'emails receive low-stock alerts',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_roleids',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Comma-separated role ids; every active member '
|
|
'of these roles receives low-stock alerts',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_supportteamid',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Support team whose webhook receives low-stock '
|
|
'alerts; empty uses the site alert_webhook_url',
|
|
},
|
|
]
|