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:
@@ -19,7 +19,8 @@ from werkzeug.security import generate_password_hash
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.plugins import plugin_manager
|
||||
from plugins.measuringtools.models import derive_status, DUESOON_WINDOW_DAYS
|
||||
from plugins.measuringtools.models import (
|
||||
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -320,3 +321,126 @@ def test_calibration_report_shape(client, auth_headers):
|
||||
for key, rows in data['buckets'].items():
|
||||
assert data['counts'][key] == len(rows)
|
||||
assert 'statuscolors' in data
|
||||
|
||||
|
||||
# -- Core integration: asset serialization ------------------------------------
|
||||
|
||||
def _caliper_id(client):
|
||||
types = client.get('/api/measuringtools/types').get_json()['data']
|
||||
return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
|
||||
|
||||
|
||||
def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers):
|
||||
"""Asset.to_dict resolves the measuringtool extension (typedata + pluginid)."""
|
||||
from shopdb.core.models import Asset
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-TD-1', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': _caliper_id(client),
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
|
||||
with mt_app.app_context():
|
||||
asset = _db.session.get(Asset, assetid)
|
||||
result = asset.to_dict(include_type_data=True)
|
||||
assert result['pluginid'] == tool_id
|
||||
assert result['typedata']['measuringtooltypename'] == 'Caliper'
|
||||
assert result['typedata']['measuringtoolid'] == tool_id
|
||||
|
||||
|
||||
# -- Core integration: shop-floor map -----------------------------------------
|
||||
|
||||
def test_map_lists_measuringtool_subtypes(client, auth_headers):
|
||||
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
|
||||
response = client.get('/api/assets/map')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
subtypes = response.get_json()['data']['filters']['subtypes']
|
||||
assert 'Measuring Tool' in subtypes
|
||||
names = {s['name'] for s in subtypes['Measuring Tool']}
|
||||
assert 'Caliper' in names
|
||||
assert all('color' in s for s in subtypes['Measuring Tool'])
|
||||
|
||||
|
||||
def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
|
||||
"""?assettype=measuring_tool&subtype=<id> returns only that subtype's tools."""
|
||||
types = client.get('/api/measuringtools/types').get_json()['data']
|
||||
caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
|
||||
micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer')
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20})
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
|
||||
|
||||
response = client.get(
|
||||
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
|
||||
assert 'MT-MAP-CAL' in numbers
|
||||
assert 'MT-MAP-MIC' not in numbers
|
||||
|
||||
|
||||
def test_map_item_carries_measuringtool_typedata(client, auth_headers):
|
||||
"""A mapped tool's item carries the extension typedata for marker coloring."""
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
|
||||
response = client.get('/api/assets/map?assettype=measuring_tool')
|
||||
item = next(a for a in response.get_json()['data']['assets']
|
||||
if a['assetnumber'] == 'MT-MAP-TD')
|
||||
assert item['typedata']['measuringtooltypename'] == 'Caliper'
|
||||
|
||||
|
||||
# -- Core integration: dashboard ----------------------------------------------
|
||||
|
||||
def test_dashboard_counts_include_measuringtools(client, auth_headers):
|
||||
"""Dashboard total and counts include active measuring tools."""
|
||||
before = client.get('/api/dashboard').get_json()['data']
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)})
|
||||
after = client.get('/api/dashboard').get_json()['data']
|
||||
assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1
|
||||
assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1
|
||||
assert after['counts']['total'] == before['counts']['total'] + 1
|
||||
|
||||
|
||||
# -- Map overlay endpoint (ADR-010) -------------------------------------------
|
||||
|
||||
def test_map_overlay_shape_and_derivation(client, auth_headers):
|
||||
"""map-overlay returns per-asset derived calibration status + color."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
|
||||
response = client.get('/api/measuringtools/map-overlay')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = response.get_json()['data']
|
||||
row = next(r for r in rows if r['assetid'] == assetid)
|
||||
assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'}
|
||||
assert row['calibrationstatus'] == 'overdue'
|
||||
assert row['statuscolor'] == STATUS_COLORS['overdue']
|
||||
|
||||
|
||||
def test_map_overlay_excludes_inactive(client, auth_headers):
|
||||
"""A soft-deleted tool drops out of the overlay."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)})
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers)
|
||||
|
||||
rows = client.get('/api/measuringtools/map-overlay').get_json()['data']
|
||||
assert all(r['assetid'] != assetid for r in rows)
|
||||
|
||||
|
||||
# -- Presentation route token (ADR-010) ---------------------------------------
|
||||
|
||||
def test_asset_presentation_route_token():
|
||||
"""Presentation route links through the by-asset resolver (only {assetid})."""
|
||||
from plugins.measuringtools.plugin import MeasuringToolsPlugin
|
||||
entries = MeasuringToolsPlugin().get_asset_presentation()
|
||||
entry = next(e for e in entries if e['assettype'] == 'measuring_tool')
|
||||
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
|
||||
|
||||
Reference in New Issue
Block a user