The device token assumes a handful of fixed screens an administrator provisions once. That is not how these kiosks are used: most people open the page on their own PC, and there is no login, so per-browser provisioning would mean touching every PC on the floor and losing it again on the next reimage or cleared profile. A screen in that state did not look refused either - the USB kiosk rendered a working page whose every call answered 404. Both kiosks now admit a caller on either ground, the same shape the GE-Enforce client endpoints already use: the per-device token, or a source address inside an allowlist. Wall screens keep the token and are not confined to the allowlist; everyone else is admitted by where they are on the network. Still gated on the site's enable flag, so nothing opens by itself, and both allowlists ship empty - token-only until a site enters its ranges. - shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0). On the surface because both plugins need the identical gate, and a CIDR parser copied into two of them is where a fail-open creeps in later. They fail closed on an empty setting, an unparseable caller address and a malformed entry, and they read remote_addr rather than raw X-Forwarded-For - so the IIS rewrite rule is what makes them meaningful, which the module says at length. - Separate settings per kiosk, usb_kiosk_allowed_cidrs and printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same reason the tokens are separate. - Both settings pages get a picker listing the network plugin's subnets with their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has to work at a site that does not run that plugin and must not break when an inventory row is deleted. Entries matching no subnet are shown rather than hidden, since a stale one left by an inventory edit looks exactly like a hand-typed range. - USBKiosk says it was not admitted instead of pretending to work, which PartsKiosk already did. - The USB settings page keyed text-vs-boolean off the token alone. Adding a second text setting to the boolean path would have saved it as the word 'true' - the bug that replaced the kiosk token and 404'd every provisioned screen. Both load and save now key off a list, with a spec on it. The trade, stated plainly in the admin UI: anyone on an allowed network can check a drive in or out against a badge number they guess, and the record carries that person's name. Badges are attribution, not authorization. Keeping the ranges narrow is what bounds it; login is the alternative and was ruled out because these users do not sign in.
231 lines
9.4 KiB
Python
231 lines
9.4 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': 27,
|
|
# Named so the nav can hide it from anybody - a signed-out
|
|
# visitor included - who does not hold it.
|
|
'permission': 'printedparts.view',
|
|
'section': 'assets',
|
|
},
|
|
]
|
|
|
|
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: set it to whatever this '
|
|
"site's labels are printed with, e.g. WJ",
|
|
},
|
|
{
|
|
'key': 'printedparts_default_threshold',
|
|
'value': '5',
|
|
'valuetype': 'integer',
|
|
'category': 'printedparts',
|
|
'description': 'Default low-stock threshold for new items',
|
|
},
|
|
{
|
|
'key': 'printedparts_kiosk_enabled',
|
|
'value': 'false',
|
|
'valuetype': 'boolean',
|
|
'category': 'printedparts',
|
|
'description': 'Serve the parts kiosk routes. Off by default: '
|
|
'they are the only unauthenticated write in the '
|
|
'product, so a site opts in',
|
|
},
|
|
{
|
|
'key': 'printedparts_kiosk_token',
|
|
'value': '',
|
|
# string, not 'secret': the column documents string/boolean/
|
|
# integer/json only. Masking is keyed on the NAME containing
|
|
# 'token' (settings.py::_is_secret), so this is still hidden in
|
|
# the API and the audit log.
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
|
|
'Generate one, then open the kiosk once as '
|
|
'/parts-kiosk?token=<value> on each screen',
|
|
},
|
|
{
|
|
'key': 'printedparts_kiosk_allowed_cidrs',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Networks allowed to use the kiosk WITHOUT the '
|
|
'device token, as comma-separated CIDRs (for '
|
|
'example 192.0.2.0/24). For people who open '
|
|
'the kiosk on their own PC and never log in. '
|
|
'Empty = token only. Separate from the USB '
|
|
'kiosk allowlist on purpose. Requires the IIS '
|
|
'X-Forwarded-For rewrite rule to be live, or the '
|
|
'app sees the proxy address and this matches '
|
|
'nothing.',
|
|
},
|
|
{
|
|
'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',
|
|
},
|
|
]
|