Add personal API tokens; wire measuring tools into remaining surfaces
API tokens: any user mints named, optionally-expiring tokens (shopdb_pat_..., sha256-stored, secret shown once) at Settings > API Tokens; a before-request shim swaps a valid PAT for a request-scoped JWT of its owner, so the entire existing auth/authz/import-mode stack works unchanged and revoked/expired tokens 401 cleanly. Built for long-running scripts - the legacy import no longer dies when a login JWT expires. Migration 7d21_apitokens; create/revoke audit-logged. Audited integration gaps fixed: Asset.to_dict serializes measuring tools (typedata + pluginid - relationship links to tools resolve); map subtype filter/colors and MapEditor include them; dashboard totals count them; warranty links use a new by-asset route; the measuringtools ADR-010 hooks are real (corrected presentation token, implemented map-overlay endpoint); the login avatar resolves through the employee-photo helper. 737 tests pass; naming green; frontend builds; both features verified live end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ from .users import users_bp
|
||||
from .customfields import customfields_bp
|
||||
from .setup import setup_bp
|
||||
from .pluginui import pluginui_bp
|
||||
from .apitokens import apitokens_bp
|
||||
|
||||
__all__ = [
|
||||
'auth_bp',
|
||||
@@ -46,4 +47,5 @@ __all__ = [
|
||||
'customfields_bp',
|
||||
'setup_bp',
|
||||
'pluginui_bp',
|
||||
'apitokens_bp',
|
||||
]
|
||||
|
||||
120
shopdb/core/api/apitokens.py
Normal file
120
shopdb/core/api/apitokens.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Personal API token management endpoints.
|
||||
|
||||
Any authenticated user may manage their OWN tokens; an admin may list or revoke
|
||||
anyone's. Endpoints are jwt_required (a token must be bootstrapped from a real
|
||||
login or an existing token). The full secret is returned ONCE, on create.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, current_user
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import ApiToken, AuditLog
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.import_mode import parse_import_datetime
|
||||
|
||||
apitokens_bp = Blueprint('apitokens', __name__)
|
||||
|
||||
|
||||
@apitokens_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_apitokens():
|
||||
"""List the caller's own tokens. Admins may pass ?all=true for everyone's.
|
||||
|
||||
Never returns hashes or secrets.
|
||||
"""
|
||||
wants_all = request.args.get('all', 'false').lower() == 'true'
|
||||
is_admin = current_user.hasrole('admin')
|
||||
|
||||
query = ApiToken.query
|
||||
if wants_all and is_admin:
|
||||
include_owner = True
|
||||
else:
|
||||
query = query.filter(ApiToken.userid == current_user.userid)
|
||||
include_owner = False
|
||||
|
||||
query = query.order_by(ApiToken.createddate.desc())
|
||||
tokens = [t.to_dict(include_owner=include_owner) for t in query.all()]
|
||||
return success_response(tokens)
|
||||
|
||||
|
||||
@apitokens_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_apitoken():
|
||||
"""Create a token for the caller. Returns the full secret ONCE."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
|
||||
expiresat = None
|
||||
if data.get('expiresat'):
|
||||
expiresat = parse_import_datetime(data.get('expiresat'))
|
||||
if expiresat is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'expiresat is not a valid date/datetime')
|
||||
|
||||
secret = ApiToken.generate_secret()
|
||||
token = ApiToken(
|
||||
userid=current_user.userid,
|
||||
name=name,
|
||||
tokenprefix=ApiToken.prefix_of(secret),
|
||||
tokenhash=ApiToken.hash_secret(secret),
|
||||
expiresat=expiresat,
|
||||
)
|
||||
db.session.add(token)
|
||||
db.session.flush()
|
||||
|
||||
AuditLog.log('created', 'ApiToken', entityid=token.tokenid, entityname=name)
|
||||
db.session.commit()
|
||||
|
||||
result = token.to_dict()
|
||||
# The secret appears here and NOWHERE else, ever. Not stored, not logged.
|
||||
result['secret'] = secret
|
||||
result['warning'] = ('Save this token now. It will not be shown again. '
|
||||
'Store it somewhere safe.')
|
||||
return success_response(result, message='Token created', http_code=201)
|
||||
|
||||
|
||||
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_apitoken(tokenid: int):
|
||||
"""Rename or deactivate a token. Own token, or any if admin."""
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404)
|
||||
|
||||
if token.userid != current_user.userid and not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You may only manage your own tokens', http_code=403)
|
||||
|
||||
data = request.get_json() or {}
|
||||
if 'name' in data:
|
||||
newname = (data.get('name') or '').strip()
|
||||
if not newname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty')
|
||||
token.name = newname
|
||||
if 'isactive' in data:
|
||||
token.isactive = bool(data['isactive'])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(token.to_dict(), message='Token updated')
|
||||
|
||||
|
||||
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def revoke_apitoken(tokenid: int):
|
||||
"""Revoke (deactivate) a token. Own token, or any if admin."""
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Token not found', http_code=404)
|
||||
|
||||
if token.userid != current_user.userid and not current_user.hasrole('admin'):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You may only manage your own tokens', http_code=403)
|
||||
|
||||
token.isactive = False
|
||||
AuditLog.log('deleted', 'ApiToken', entityid=token.tokenid, entityname=token.name)
|
||||
db.session.commit()
|
||||
return success_response(message='Token revoked')
|
||||
@@ -913,6 +913,14 @@ def get_assets_map():
|
||||
)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
eager_options.append(
|
||||
subqueryload(Asset.measuringtool)
|
||||
.joinedload(MeasuringTool.measuringtooltype)
|
||||
)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
query = Asset.query.options(*eager_options).filter(
|
||||
Asset.isactive == True,
|
||||
@@ -941,7 +949,10 @@ def get_assets_map():
|
||||
# Filter by subtype (depends on asset type) - case-insensitive matching
|
||||
if subtype_id := request.args.get('subtype'):
|
||||
subtype_id = int(subtype_id)
|
||||
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
|
||||
# Normalize the underscore DB form (measuring_tool, network_device) to
|
||||
# the space form the branches below compare against.
|
||||
asset_type_lower = (
|
||||
selected_assettype.lower().replace('_', ' ') if selected_assettype else '')
|
||||
if asset_type_lower == 'machine':
|
||||
try:
|
||||
from plugins.machines.models import Machine
|
||||
@@ -974,6 +985,15 @@ def get_assets_map():
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
elif asset_type_lower == 'measuring tool':
|
||||
try:
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
query = query.join(
|
||||
MeasuringTool, MeasuringTool.assetid == Asset.assetid).filter(
|
||||
MeasuringTool.measuringtooltypeid == subtype_id
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Filter by business unit
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
@@ -1100,6 +1120,14 @@ def get_assets_map():
|
||||
except ImportError:
|
||||
subtypes['Printer'] = []
|
||||
|
||||
try:
|
||||
from plugins.measuringtools.models import MeasuringToolType
|
||||
measuringtool_types = MeasuringToolType.query.filter(
|
||||
MeasuringToolType.isactive == True).order_by(MeasuringToolType.name).all()
|
||||
subtypes['Measuring Tool'] = [{'id': mt.measuringtooltypeid, 'name': mt.name, 'color': mt.color} for mt in measuringtool_types]
|
||||
except ImportError:
|
||||
subtypes['Measuring Tool'] = []
|
||||
|
||||
return success_response({
|
||||
'assets': data,
|
||||
'total': len(data),
|
||||
|
||||
@@ -15,6 +15,7 @@ _TYPE_CATEGORY = {
|
||||
'computer': 'PC',
|
||||
'printer': 'Printer',
|
||||
'network_device': 'Network',
|
||||
'measuring_tool': 'Measuring Tool',
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +46,8 @@ def get_dashboard():
|
||||
pc_count = _count_by_type('computer')
|
||||
network_count = _count_by_type('network_device')
|
||||
printer_count = _count_by_type('printer')
|
||||
total = machine_count + pc_count + network_count + printer_count
|
||||
measuringtool_count = _count_by_type('measuring_tool')
|
||||
total = machine_count + pc_count + network_count + printer_count + measuringtool_count
|
||||
|
||||
# Count by status
|
||||
status_counts = db.session.query(
|
||||
@@ -70,6 +72,7 @@ def get_dashboard():
|
||||
'totalpc': pc_count,
|
||||
'totalnetwork': network_count,
|
||||
'totalprinter': printer_count,
|
||||
'totalmeasuringtool': measuringtool_count,
|
||||
'activeassets': status_dict.get('In Use', 0),
|
||||
'inrepair': status_dict.get('In Repair', 0),
|
||||
# Structured data
|
||||
@@ -78,6 +81,7 @@ def get_dashboard():
|
||||
'pcs': pc_count,
|
||||
'networkdevices': network_count,
|
||||
'printers': printer_count,
|
||||
'measuringtools': measuringtool_count,
|
||||
'total': total
|
||||
},
|
||||
'bystatus': status_dict,
|
||||
|
||||
Reference in New Issue
Block a user