Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point.
194 lines
7.5 KiB
Python
194 lines
7.5 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_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',
|
|
},
|
|
]
|