Merge remote-tracking branch 'origin/main' into wilm

# Conflicts:
#	shopdb/__init__.py
This commit is contained in:
benjamin.baker
2026-08-28 09:30:35 -04:00
112 changed files with 5502 additions and 604 deletions

View File

@@ -86,7 +86,6 @@ def list_apitokens():
@apitokens_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('apitokens.create')
def create_apitoken():
"""Create a token for the caller. Returns the full secret ONCE.
@@ -141,7 +140,6 @@ def create_apitoken():
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
@jwt_required()
@require_permission('apitokens.create')
def update_apitoken(tokenid: int):
"""Rename, rescope, or deactivate a token. Own token, or any if admin.
@@ -183,7 +181,6 @@ def update_apitoken(tokenid: int):
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
@jwt_required()
@require_permission('apitokens.create')
def revoke_apitoken(tokenid: int):
"""Revoke (deactivate) a token. Own token, or any if admin."""

View File

@@ -118,7 +118,7 @@ applications_bp = Blueprint('applications', __name__)
@applications_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('applications.view')
def list_applications():
"""List all applications."""
page, per_page = get_pagination_params(request)
@@ -163,7 +163,7 @@ def list_applications():
@applications_bp.route('/<int:app_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('applications.view')
def get_application(app_id: int):
"""Get a single application with details."""
app = db.session.get(Application, app_id)
@@ -180,7 +180,6 @@ def get_application(app_id: int):
@applications_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_application():
"""Create a new application."""
@@ -224,7 +223,6 @@ def create_application():
@applications_bp.route('/<int:app_id>', methods=['PUT'])
@jwt_required()
@require_permission('applications.edit')
def update_application(app_id: int):
"""Update an application."""
@@ -270,7 +268,6 @@ def update_application(app_id: int):
@applications_bp.route('/<int:app_id>', methods=['DELETE'])
@jwt_required()
@require_permission('applications.delete')
def delete_application(app_id: int):
"""Delete (deactivate) an application."""
@@ -291,7 +288,7 @@ def delete_application(app_id: int):
# ---- Versions ----
@applications_bp.route('/<int:app_id>/versions', methods=['GET'])
@jwt_required(optional=True)
@require_permission('applications.view')
def list_versions(app_id: int):
"""List all versions for an application."""
app = db.session.get(Application, app_id)
@@ -303,7 +300,6 @@ def list_versions(app_id: int):
@applications_bp.route('/<int:app_id>/versions', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def create_version(app_id: int):
"""Create a new version for an application."""
@@ -341,7 +337,7 @@ def create_version(app_id: int):
# ---- Computers with this app installed ----
@applications_bp.route('/<int:app_id>/installed', methods=['GET'])
@jwt_required(optional=True)
@require_permission('applications.view')
def list_installed_machines(app_id: int):
"""List all computers that have this application installed."""
app = db.session.get(Application, app_id)
@@ -380,7 +376,7 @@ def list_installed_machines(app_id: int):
# ---- Installed Apps (per computer) ----
@applications_bp.route('/machines/<int:machine_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('applications.view')
def list_machine_applications(machine_id: int):
"""List all applications installed on a computer."""
models, err = _require_computer_models()
@@ -397,7 +393,6 @@ def list_machine_applications(machine_id: int):
@applications_bp.route('/machines/<int:machine_id>', methods=['POST'])
@jwt_required()
@require_permission('applications.create')
def install_application(machine_id: int):
"""Install an application on a computer."""
@@ -449,7 +444,6 @@ def install_application(machine_id: int):
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['DELETE'])
@jwt_required()
@require_permission('applications.delete')
def uninstall_application(machine_id: int, app_id: int):
"""Uninstall an application from a computer."""
@@ -474,7 +468,6 @@ def uninstall_application(machine_id: int, app_id: int):
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['PUT'])
@jwt_required()
@require_permission('applications.edit')
def update_installed_app(machine_id: int, app_id: int):
"""Update installed application (e.g., change version)."""
@@ -516,7 +509,6 @@ def update_installed_app(machine_id: int, app_id: int):
# =============================================================================
@applications_bp.route('/<int:app_id>/image', methods=['POST'])
@jwt_required()
@require_permission('applications.edit')
def upload_application_image(app_id: int):
"""Upload (or replace) an application's image.
@@ -549,13 +541,13 @@ def upload_application_image(app_id: int):
@applications_bp.route('/image/<path:filename>', methods=['GET'])
@require_permission('applications.view')
def serve_application_image(filename):
"""Serve an uploaded application image (public - lists and tiles read it)."""
return send_upload(_appimage_dir(), filename)
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
@jwt_required()
@require_permission('applications.edit')
def delete_application_image(app_id: int):
"""Remove an application's image and clear the field."""
@@ -574,7 +566,6 @@ def delete_application_image(app_id: int):
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
@jwt_required()
@require_permission('applications.edit')
def upload_application_package(app_id: int):
"""Upload (or replace) the installer for an application.
@@ -627,7 +618,6 @@ def upload_application_package(app_id: int):
@applications_bp.route('/package/<path:filename>', methods=['GET'])
@jwt_required()
@require_permission('applications.view')
def serve_application_package(filename):
"""Download an uploaded installer.
@@ -640,7 +630,6 @@ def serve_application_package(filename):
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])
@jwt_required()
@require_permission('applications.edit')
def delete_application_package(app_id: int):
"""Remove an uploaded installer and clear the path it set."""

View File

@@ -25,7 +25,7 @@ assets_bp = Blueprint('assets', __name__)
# =============================================================================
@assets_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_asset_types():
"""List all asset types."""
page, per_page = get_pagination_params(request)
@@ -44,7 +44,7 @@ def list_asset_types():
@assets_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_asset_type(type_id: int):
"""Get a single asset type."""
t = db.session.get(AssetType, type_id)
@@ -60,7 +60,6 @@ def get_asset_type(type_id: int):
@assets_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_type():
"""Create a new asset type."""
@@ -92,7 +91,6 @@ def create_asset_type():
@assets_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_type(type_id: int):
"""Update an asset type's display fields (color/icon/description). The name,
@@ -113,7 +111,7 @@ def update_asset_type(type_id: int):
# =============================================================================
@assets_bp.route('/statuses', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_asset_statuses():
"""List all asset statuses."""
page, per_page = get_pagination_params(request)
@@ -132,7 +130,7 @@ def list_asset_statuses():
@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_asset_status(status_id: int):
"""Get a single asset status."""
s = db.session.get(AssetStatus, status_id)
@@ -148,7 +146,6 @@ def get_asset_status(status_id: int):
@assets_bp.route('/statuses', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_status():
"""Create a new asset status."""
@@ -177,7 +174,6 @@ def create_asset_status():
@assets_bp.route('/statuses/<int:status_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_status(status_id: int):
"""Update an asset status."""
@@ -206,7 +202,6 @@ def update_asset_status(status_id: int):
@assets_bp.route('/statuses/<int:status_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_status(status_id: int):
"""Delete an asset status. Refused if any asset still uses it."""
@@ -233,7 +228,7 @@ def delete_asset_status(status_id: int):
# =============================================================================
@assets_bp.route('/relationshiptypes', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_relationship_types():
"""List all asset relationship types."""
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
@@ -241,7 +236,6 @@ def list_relationship_types():
@assets_bp.route('/relationshiptypes', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_relationship_type():
"""Create a new asset relationship type."""
@@ -288,7 +282,6 @@ def _rel_type_dict(t):
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_relationship_type(type_id: int):
"""Update a relationship type."""
@@ -308,7 +301,6 @@ def update_relationship_type(type_id: int):
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_relationship_type(type_id: int):
"""Delete a relationship type. Refused if any relationship still uses it."""
@@ -329,7 +321,7 @@ def delete_relationship_type(type_id: int):
# =============================================================================
@assets_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_assets():
"""
List all assets with filtering and pagination.
@@ -415,7 +407,7 @@ def list_assets():
@assets_bp.route('/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_asset(asset_id: int):
"""
Get a single asset with full details.
@@ -437,7 +429,6 @@ def get_asset(asset_id: int):
@assets_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset():
"""Create a new asset."""
@@ -489,7 +480,6 @@ def create_asset():
@assets_bp.route('/<int:asset_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset(asset_id: int):
"""Update an asset."""
@@ -542,7 +532,6 @@ def update_asset(asset_id: int):
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset(asset_id: int):
"""Delete (soft delete) an asset."""
@@ -562,7 +551,7 @@ def delete_asset(asset_id: int):
@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def lookup_asset_by_number(assetnumber: str):
"""
Look up an asset by its asset number.
@@ -683,7 +672,7 @@ def propagate_relationship(rel):
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_asset_relationships(asset_id: int):
"""
Get all relationships for an asset.
@@ -733,7 +722,6 @@ def get_asset_relationships(asset_id: int):
@assets_bp.route('/relationships', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_relationship():
"""Create a relationship between two assets.
@@ -884,7 +872,6 @@ def create_asset_relationship():
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_relationship(rel_id: int):
"""Delete an asset relationship.
@@ -917,7 +904,7 @@ def delete_asset_relationship(rel_id: int):
# =============================================================================
@assets_bp.route('/map', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_assets_map():
"""
Get all assets with map positions for unified floor map display.
@@ -1239,7 +1226,7 @@ def get_assets_map():
@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_asset_communications(asset_id: int):
"""Get all communications for an asset."""
from shopdb.core.models import Communication

View File

@@ -52,7 +52,6 @@ def _resolve_full_names(ssos):
@auditlogs_bp.route('', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def list_auditlogs():
"""
@@ -136,7 +135,6 @@ def list_auditlogs():
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_entity_history(entitytype: str, entityid: int):
"""Get audit history for a specific entity."""
@@ -149,7 +147,6 @@ def get_entity_history(entitytype: str, entityid: int):
@auditlogs_bp.route('/stats', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_stats():
"""Get audit log statistics."""
@@ -198,7 +195,6 @@ def db_func_count_by(column):
@auditlogs_bp.route('/retention', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_retention():
"""Size of the log and what the configured retention would remove.
@@ -212,7 +208,6 @@ def get_retention():
@auditlogs_bp.route('/purge', methods=['POST'])
@jwt_required()
@require_permission('audit.purge')
def purge_auditlogs():
"""Delete entries older than the retention window.

View File

@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.authz import require_permission
businessunits_bp = Blueprint('businessunits', __name__)
@@ -51,7 +52,7 @@ def list_businessunits():
@businessunits_bp.route('/<int:bu_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_businessunit(bu_id: int):
"""Get a single business unit."""
bu = db.session.get(BusinessUnit, bu_id)

View File

@@ -15,6 +15,7 @@ from shopdb.utils.responses import (
ErrorCodes
)
from shopdb.utils.authz import require_role
from shopdb.utils.authz import require_permission
customfields_bp = Blueprint('customfields', __name__)
@@ -45,7 +46,7 @@ def _normalize_options(raw):
# =============================================================================
@customfields_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_fields():
"""List custom-field definitions. Filter by ?assettypeid=. ?active=false
includes inactive ones."""
@@ -171,7 +172,7 @@ def _fields_with_values(asset):
@customfields_bp.route('/asset/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_asset_fields(assetid):
"""Active custom fields for an asset's type, merged with its values."""
asset = db.session.get(Asset, assetid)

View File

@@ -6,6 +6,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, AssetStatus
from shopdb.utils.responses import success_response
from shopdb.utils.authz import require_permission
dashboard_bp = Blueprint('dashboard', __name__)
@@ -65,7 +66,7 @@ def _countedassets():
@dashboard_bp.route('/summary', methods=['GET'])
@dashboard_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
machine_count = _count_by_type('machine')
@@ -121,7 +122,7 @@ def get_dashboard():
@dashboard_bp.route('/stats', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_stats():
"""Get detailed statistics by asset type."""
type_counts = db.session.query(
@@ -154,9 +155,14 @@ def get_navigation():
all_items = []
# Core navigation items (always present)
# Every item names the permission its page needs, so the filter below can
# hide what the caller cannot reach - a link that only leads to a 401 is
# worse than no link.
all_items.extend([
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0},
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4},
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/',
'position': 0, 'permission': 'assets.view'},
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4,
'permission': 'assets.view'},
])
# Collect navigation items from enabled plugins. Disabling persists to the
@@ -180,18 +186,41 @@ def get_navigation():
# Add core information section items
all_items.extend([
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'},
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications',
'position': 30, 'section': 'information',
'permission': 'applications.view'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports',
'position': 40, 'section': 'information', 'permission': 'reports.view'},
])
# Sort by position
all_items.sort(key=lambda x: x.get('position', 99))
# A signed-out visitor is shown only what the guest role can actually
# reach. Without this the sidebar offers them the whole app and every link
# bounces off a 401, which reads as a broken site rather than a closed
# door. An item with no declared permission is treated as members-only: a
# plugin opts into being guest-visible by naming the permission its page
# needs, which is the same string the API already gates on.
from flask_jwt_extended import verify_jwt_in_request
from shopdb.utils.authz import currentuser, guestholds
verify_jwt_in_request(optional=True)
def guestmayreach(item):
declared = item.get('permission')
if not declared:
return False
names = declared if isinstance(declared, list) else [declared]
return any(guestholds(name) for name in names)
if currentuser() is None:
all_items = [item for item in all_items if guestmayreach(item)]
return success_response(all_items)
@dashboard_bp.route('/widgets', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_widgets():
"""Aggregate dashboard widget definitions from all enabled plugins.

View File

@@ -13,6 +13,7 @@ from shopdb.core.models.dashboarddefault import (
DISPLAY_ROLES, DISPLAY_ROLE_PATHS, normalize_display_role)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
from shopdb.utils.authz import require_permission
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
@@ -102,7 +103,7 @@ def display_role():
@dashboarddefaults_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('settings.view')
def list_defaults():
"""List all visitor-IP -> business-unit mappings."""
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(

View File

@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.authz import require_permission
locations_bp = Blueprint('locations', __name__)
@@ -30,7 +31,7 @@ def _loc_type_dict(t):
@locations_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_location_types():
"""List location types. ?active=false includes inactive ones."""
query = LocationType.query
@@ -101,7 +102,7 @@ def delete_location_type(type_id):
@locations_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_locations():
"""List all locations."""
page, per_page = get_pagination_params(request)
@@ -132,7 +133,7 @@ def list_locations():
@locations_bp.route('/<int:location_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_location(location_id: int):
"""Get a single location."""
loc = db.session.get(Location, location_id)

View File

@@ -20,6 +20,7 @@ from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
from shopdb.api import send_upload
from shopdb.utils.imagesize import image_size
from shopdb.utils.authz import require_permission
maplevels_bp = Blueprint('maplevels', __name__)
@@ -38,6 +39,7 @@ def _blueprint_dir():
# =============================================================================
@maplevels_bp.route('', methods=['GET'])
@require_permission('assets.view')
def list_levels():
"""Every active level, grouped by building, in display order.
@@ -72,6 +74,7 @@ def list_levels():
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
@require_permission('assets.view')
def get_level(levelid):
level = db.session.get(MapLevel, levelid)
if not level or not level.isactive:
@@ -81,6 +84,7 @@ def get_level(levelid):
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
@require_permission('assets.view')
def serve_blueprint(filename, levelid=None):
"""Serve a level's blueprint image. Public, for the same reason as above."""
return send_upload(_blueprint_dir(), filename)

View File

@@ -115,7 +115,6 @@ def apply_transform(asset, transform):
@mappositions_bp.route('/transform', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def transform_positions():
"""Move every placed marker on a level by a transform read off landmarks.
@@ -225,7 +224,6 @@ def transform_positions():
# =============================================================================
@mappositions_bp.route('/positions', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def set_positions():
"""Set positions for many assets at once.
@@ -301,7 +299,6 @@ def set_positions():
@mappositions_bp.route('/verify', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def verify_positions():
"""Mark markers as reviewed against the current drawing without moving them.
@@ -330,7 +327,6 @@ def verify_positions():
# =============================================================================
@mappositions_bp.route('/snapshots', methods=['GET'])
@jwt_required()
@require_permission('assets.view')
def list_snapshots():
"""Snapshots, newest first. Metadata only - the positions are large."""
@@ -341,7 +337,6 @@ def list_snapshots():
@mappositions_bp.route('/snapshots/<int:snapshotid>/restore', methods=['POST'])
@jwt_required()
@require_permission('assets.edit')
def restore_snapshot(snapshotid):
"""Put the positions in this snapshot back.

View File

@@ -20,6 +20,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.uploads import send_upload
from shopdb.utils.authz import require_permission
models_bp = Blueprint('models', __name__)
@@ -39,7 +40,7 @@ def _modelimage_dir():
@models_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_models():
"""List all vendor catalog models."""
page, per_page = get_pagination_params(request)
@@ -85,7 +86,7 @@ def list_models():
@models_bp.route('/<int:model_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_model(model_id: int):
"""Get a single model."""
m = db.session.get(Model, model_id)
@@ -234,6 +235,7 @@ def upload_model_image(model_id: int):
@models_bp.route('/image/<path:filename>', methods=['GET'])
@require_permission('assets.view')
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_upload(_modelimage_dir(), filename)

View File

@@ -19,12 +19,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.authz import require_permission
modeltypes_bp = Blueprint('modeltypes', __name__)
@modeltypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_modeltypes():
"""List all model types with optional filtering."""
page, per_page = get_pagination_params(request)
@@ -53,7 +54,7 @@ def list_modeltypes():
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_modeltype(type_id: int):
"""Get a single model type."""
mt = db.session.get(ModelType, type_id)

View File

@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.authz import require_permission
operatingsystems_bp = Blueprint('operatingsystems', __name__)
@operatingsystems_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_operatingsystems():
"""List all operating systems."""
page, per_page = get_pagination_params(request)
@@ -49,7 +50,7 @@ def list_operatingsystems():
@operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_operatingsystem(os_id: int):
"""Get a single operating system."""
os = db.session.get(OperatingSystem, os_id)

View File

@@ -6,12 +6,13 @@ from flask_jwt_extended import jwt_required
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
from shopdb.utils.authz import require_permission
plugins_bp = Blueprint('plugins', __name__)
@plugins_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('settings.view')
def list_plugins():
"""List all discovered plugins (enabled or not) with their metadata and
the framework contract version."""

View File

@@ -15,6 +15,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
pluginui_bp = Blueprint('pluginui', __name__)
@@ -47,7 +48,7 @@ def _collect(hookname):
@pluginui_bp.route('/settings-cards', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def settings_cards():
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
@@ -60,7 +61,7 @@ def settings_cards():
@pluginui_bp.route('/asset-panels', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def asset_panels():
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
@@ -86,7 +87,7 @@ def asset_panels():
@pluginui_bp.route('/map-overlays', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def map_overlays():
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
@@ -98,7 +99,7 @@ def map_overlays():
@pluginui_bp.route('/asset-presentation', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def asset_presentation():
"""Merge enabled plugins' asset-type presentation entries (hook).

View File

@@ -32,7 +32,7 @@ def generate_csv(data: list, columns: list) -> str:
# =============================================================================
@reports_bp.route('/machines-by-type', methods=['GET'])
@jwt_required(optional=True)
@require_permission('reports.view')
def machines_by_type():
"""
Report: Machine count grouped by machine type.
@@ -107,7 +107,7 @@ def machines_by_type():
# =============================================================================
@reports_bp.route('/assets-by-status', methods=['GET'])
@jwt_required(optional=True)
@require_permission('reports.view')
def assets_by_status():
"""
Report: Asset count grouped by status.
@@ -166,7 +166,7 @@ def assets_by_status():
# =============================================================================
@reports_bp.route('/kb-popularity', methods=['GET'])
@jwt_required(optional=True)
@require_permission('reports.view')
def kb_popularity():
"""
Report: Most clicked knowledge base articles.
@@ -223,7 +223,7 @@ def kb_popularity():
# =============================================================================
@reports_bp.route('/software-compliance', methods=['GET'])
@jwt_required(optional=True)
@require_permission('reports.view')
def software_compliance():
"""
Report: Required applications vs installed (per PC).
@@ -342,7 +342,7 @@ def software_compliance():
# =============================================================================
@reports_bp.route('/asset-inventory', methods=['GET'])
@jwt_required(optional=True)
@require_permission('reports.view')
def asset_inventory():
"""
Report: Complete asset inventory summary.
@@ -456,7 +456,7 @@ def asset_inventory():
# =============================================================================
@reports_bp.route('/pc-relationships', methods=['GET'])
@jwt_required(optional=True)
@require_permission('reports.view')
def pc_relationships():
"""
Report: PCs with relationships to shop floor machines.
@@ -525,7 +525,6 @@ def pc_relationships():
@reports_bp.route('/email', methods=['POST'])
@jwt_required()
@require_permission('reports.export')
def email_report():
"""Email a report's data as an HTML table (on-demand delivery).
@@ -574,7 +573,7 @@ def email_report():
@reports_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('reports.view')
def list_reports():
"""List all available reports."""
reports = [

View File

@@ -18,6 +18,7 @@ from shopdb.core.models import (
from shopdb.core.api.settings import get_cached_settings
from shopdb.core.services.spellfix import Vocabulary
from shopdb.utils.responses import success_response
from shopdb.utils.authz import require_permission
logger = logging.getLogger(__name__)
@@ -1161,7 +1162,7 @@ def _suggestion_for(query):
@search_bp.route('/suggest', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def search_suggest():
"""A better spelling for a query that found nothing, or null.
@@ -1185,7 +1186,7 @@ def search_suggest():
@search_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def global_search():
"""
Global search across multiple entity types.

View File

@@ -576,7 +576,6 @@ def get_setting(key: str):
@settings_bp.route('/<key>', methods=['PUT'])
@jwt_required()
@require_permission('settings.edit')
def update_setting(key: str):
"""Update a setting value."""
@@ -635,7 +634,6 @@ def update_setting(key: str):
@settings_bp.route('/test-email', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def test_email():
"""Send a test email to verify SMTP configuration.
@@ -673,7 +671,6 @@ def test_email():
@settings_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def create_setting():
"""Create a new setting (admin only)."""
@@ -1299,7 +1296,6 @@ def build_default_settings():
@settings_bp.route('/seed', methods=['POST'])
@jwt_required()
@require_permission('settings.edit')
def seed_default_settings():
"""Seed default settings if they don't exist."""

View File

@@ -14,12 +14,13 @@ from shopdb.utils.responses import (
)
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.authz import require_permission
supportteams_bp = Blueprint('supportteams', __name__)
@supportteams_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_support_teams():
"""List support teams (with contacts). ?active and ?teamname filters."""
query = SupportTeam.query
@@ -36,7 +37,7 @@ def list_support_teams():
@supportteams_bp.route('/<int:team_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_support_team(team_id: int):
"""Get a single support team with its contacts."""
team = db.session.get(SupportTeam, team_id)

View File

@@ -6,7 +6,8 @@ from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog)
User, Role, Permission, AuditLog, full_permission_catalog,
guest_grantable_permissions)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
@@ -266,6 +267,9 @@ def list_permissions():
def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
# What a guest MAY be granted, so the grid can grey out the rest rather
# than letting somebody tick a box the API will refuse.
guestgrantable = sorted(guest_grantable_permissions())
return success_response([{
'roleid': r.roleid,
'rolename': r.rolename,
@@ -273,7 +277,9 @@ def list_roles():
'color': r.color,
'usercount': r.users.count(),
'permissions': [p.name for p in r.permissions],
'isadmin': r.rolename == 'admin'
'isadmin': r.rolename == 'admin',
'isguest': r.rolename == Role.GUEST,
'grantable': guestgrantable if r.rolename == Role.GUEST else None,
} for r in roles])
@@ -344,6 +350,20 @@ def update_role(roleid: int):
# Update permissions
if 'permissions' in data and role.rolename != 'admin':
# The guest role is anyone with no account, so what it may hold is
# bounded: reads, plus the handful of writes a plugin has explicitly
# declared safe to do anonymously. Refused at the door rather than
# warned about - a checkbox that quietly makes deleting an asset
# anonymous is not a warning anybody reads twice.
if role.rolename == Role.GUEST:
allowed = guest_grantable_permissions()
refused = sorted(set(data['permissions']) - allowed)
if refused:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The guest role cannot hold {}. A guest may be granted any '
'view permission, and only writes a plugin has declared '
'safe to do without an account.'.format(', '.join(refused)))
old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
new_perms = [p.name for p in perms]
@@ -377,6 +397,14 @@ def delete_role(roleid: int):
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
# Deleting the guest role does not close the door, it removes the only
# place the door is described. Revoke its permissions instead.
if role.rolename == Role.GUEST:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Cannot delete the guest role. To close guest access, clear its '
'permissions.')
if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')

View File

@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.authz import require_permission
vendors_bp = Blueprint('vendors', __name__)
@vendors_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def list_vendors():
"""List all vendors."""
page, per_page = get_pagination_params(request)
@@ -46,7 +47,7 @@ def list_vendors():
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('assets.view')
def get_vendor(vendor_id: int):
"""Get a single vendor."""
v = db.session.get(Vendor, vendor_id)