The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""Tests for the public shopdb.api namespace exposed to plugins.
|
|
|
|
Pins audit_log, resolve_asset_position, and the BasePlugin
|
|
get_setting/set_setting helpers as part of the contract surface.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from shopdb.api import audit_log, resolve_asset_position
|
|
from shopdb.core.models import AuditLog, Setting
|
|
|
|
|
|
def test_audit_log_creates_row(db, client):
|
|
"""audit_log writes an AuditLog row with the standard fields."""
|
|
with client.application.test_request_context('/'):
|
|
entry = audit_log(
|
|
action='created',
|
|
entitytype='Computer',
|
|
entityid=42,
|
|
entityname='PC-1234',
|
|
changes={'before': {}, 'after': {'hostname': 'PC-1234'}},
|
|
)
|
|
|
|
assert entry is not None
|
|
assert entry.action == 'created'
|
|
assert entry.entitytype == 'Computer'
|
|
assert entry.entityid == 42
|
|
assert entry.entityname == 'PC-1234'
|
|
|
|
saved = AuditLog.query.filter_by(entityid=42, entitytype='Computer').first()
|
|
assert saved is not None
|
|
|
|
|
|
def test_resolve_asset_position_returns_none_when_no_data():
|
|
"""An asset with no coords and no location returns None."""
|
|
class FakeAsset:
|
|
mapx = None
|
|
mapy = None
|
|
location = None
|
|
|
|
assert resolve_asset_position(FakeAsset()) is None
|
|
|
|
|
|
def test_resolve_asset_position_uses_self_when_set():
|
|
"""Asset-specific coords win over everything else."""
|
|
class FakeLocation:
|
|
mapx = 10
|
|
mapy = 20
|
|
levelid = 9
|
|
|
|
class FakeAsset:
|
|
mapx = 100
|
|
mapy = 200
|
|
levelid = 7
|
|
location = FakeLocation()
|
|
|
|
result = resolve_asset_position(FakeAsset())
|
|
assert result == {'mapx': 100, 'mapy': 200, 'levelid': 7,
|
|
'positionsource': 'self'}
|
|
|
|
|
|
def test_resolve_asset_position_falls_back_to_location():
|
|
"""When asset has no coords, falls back to location coords.
|
|
|
|
The level comes with them (ADR-017). The asset here carries a STALE levelid
|
|
of its own with no coordinates to go with it - a leftover from a position
|
|
that was cleared - and returning that level beside the location's
|
|
coordinates would draw the location's spot on the wrong drawing.
|
|
"""
|
|
class FakeLocation:
|
|
mapx = 50
|
|
mapy = 75
|
|
levelid = 9
|
|
|
|
class FakeAsset:
|
|
mapx = None
|
|
mapy = None
|
|
levelid = 7
|
|
location = FakeLocation()
|
|
|
|
result = resolve_asset_position(FakeAsset())
|
|
assert result == {'mapx': 50, 'mapy': 75, 'levelid': 9,
|
|
'positionsource': 'location'}
|
|
|
|
|
|
def test_resolve_asset_position_handles_asset_without_mapx_attr():
|
|
"""Assets that don't yet have mapx/mapy columns degrade gracefully.
|
|
|
|
Nor a levelid attribute: a plugin extension row passed in here has none,
|
|
and reading it must not raise.
|
|
"""
|
|
class FakeLocation:
|
|
mapx = 1
|
|
mapy = 2
|
|
levelid = 4
|
|
|
|
class FakeAsset:
|
|
location = FakeLocation()
|
|
|
|
result = resolve_asset_position(FakeAsset())
|
|
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 4,
|
|
'positionsource': 'location'}
|
|
|
|
|
|
# --- Relationship-walk path (priority 2 in the chain) ----------------------
|
|
|
|
def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True):
|
|
"""Build a fake AssetRelationship-shaped object pointing at neighbor.
|
|
`neighbor` is wired as both source and target so the walk helper finds
|
|
it regardless of direction; tests pick which list to attach it to."""
|
|
class FakeRelType:
|
|
relationshiptype = rtype_name
|
|
|
|
class FakeRel:
|
|
pass
|
|
|
|
r = FakeRel()
|
|
r.relationshiptype = FakeRelType()
|
|
r.inheritsposition = inheritsposition
|
|
r.isactive = isactive
|
|
r.targetasset = neighbor
|
|
r.sourceasset = neighbor
|
|
return r
|
|
|
|
|
|
def _make_asset(assetid, mapx=None, mapy=None, levelid=None, outgoing=None,
|
|
incoming=None, location=None):
|
|
class FakeAsset:
|
|
pass
|
|
|
|
a = FakeAsset()
|
|
a.assetid = assetid
|
|
a.mapx = mapx
|
|
a.mapy = mapy
|
|
a.levelid = levelid
|
|
a.outgoing_relationships = outgoing or []
|
|
a.incoming_relationships = incoming or []
|
|
a.location = location
|
|
return a
|
|
|
|
|
|
def test_resolve_asset_position_walks_partof_edge():
|
|
"""Priority 2: inheritsposition=true partof edge resolves from neighbor."""
|
|
parent = _make_asset(assetid=2, mapx=300, mapy=400, levelid=2)
|
|
rel = _make_rel('partof', parent)
|
|
child = _make_asset(assetid=1, outgoing=[rel])
|
|
|
|
result = resolve_asset_position(child)
|
|
assert result == {'mapx': 300, 'mapy': 400, 'levelid': 2,
|
|
'positionsource': 'related'}
|
|
|
|
|
|
def test_resolve_asset_position_walks_controls_after_partof():
|
|
"""Priority 2 ordering: partof beats controls when both have coords."""
|
|
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12, levelid=3)
|
|
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99, levelid=8)
|
|
rel_partof = _make_rel('partof', partof_neighbor)
|
|
rel_controls = _make_rel('controls', controls_neighbor)
|
|
asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof])
|
|
|
|
result = resolve_asset_position(asset)
|
|
assert result == {'mapx': 11, 'mapy': 12, 'levelid': 3,
|
|
'positionsource': 'related'}
|
|
|
|
|
|
def test_resolve_asset_position_skips_non_inheritable_type():
|
|
"""connectedto edges are never walked even if inheritsposition is true."""
|
|
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
|
rel = _make_rel('connectedto', neighbor, inheritsposition=True)
|
|
asset = _make_asset(assetid=1, outgoing=[rel])
|
|
|
|
assert resolve_asset_position(asset) is None
|
|
|
|
|
|
def test_resolve_asset_position_skips_when_inheritsposition_false():
|
|
"""An edge with inheritsposition=false is not walked."""
|
|
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
|
rel = _make_rel('partof', neighbor, inheritsposition=False)
|
|
asset = _make_asset(assetid=1, outgoing=[rel])
|
|
|
|
assert resolve_asset_position(asset) is None
|
|
|
|
|
|
def test_resolve_asset_position_walks_recursively():
|
|
"""The walk recurses: child -> middle -> root, where only root has coords."""
|
|
root = _make_asset(assetid=3, mapx=1, mapy=2, levelid=5)
|
|
middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)])
|
|
child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)])
|
|
|
|
result = resolve_asset_position(child)
|
|
# Carried back up two hops from the node that actually has coordinates.
|
|
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 5,
|
|
'positionsource': 'related'}
|
|
|
|
|
|
def test_resolve_asset_position_breaks_cycles():
|
|
"""A cycle A<->B with no coords anywhere returns None without recursing
|
|
forever."""
|
|
a = _make_asset(assetid=1)
|
|
b = _make_asset(assetid=2)
|
|
a.outgoing_relationships = [_make_rel('partof', b)]
|
|
b.outgoing_relationships = [_make_rel('partof', a)]
|
|
|
|
assert resolve_asset_position(a) is None
|
|
|
|
|
|
def test_resolve_asset_position_depth_cap_is_three():
|
|
"""Past depth 3 the walk gives up. Build a chain of 5 nodes where only
|
|
the last has coords; expect None."""
|
|
coords_node = _make_asset(assetid=5, mapx=99, mapy=99)
|
|
n4 = _make_asset(assetid=4, outgoing=[_make_rel('partof', coords_node)])
|
|
n3 = _make_asset(assetid=3, outgoing=[_make_rel('partof', n4)])
|
|
n2 = _make_asset(assetid=2, outgoing=[_make_rel('partof', n3)])
|
|
root = _make_asset(assetid=1, outgoing=[_make_rel('partof', n2)])
|
|
|
|
assert resolve_asset_position(root) is None
|
|
|
|
|
|
def test_resolve_asset_position_self_beats_related():
|
|
"""Priority 1 beats priority 2: asset's own coords win even when a
|
|
related neighbor would also resolve."""
|
|
neighbor = _make_asset(assetid=2, mapx=99, mapy=99, levelid=9)
|
|
rel = _make_rel('partof', neighbor)
|
|
asset = _make_asset(assetid=1, mapx=1, mapy=2, levelid=1, outgoing=[rel])
|
|
|
|
result = resolve_asset_position(asset)
|
|
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 1,
|
|
'positionsource': 'self'}
|
|
|
|
|
|
def test_resolve_asset_position_related_beats_location():
|
|
"""Priority 2 beats priority 3: a related neighbor's coords win over
|
|
the asset's location coords."""
|
|
class FakeLocation:
|
|
mapx = 500
|
|
mapy = 600
|
|
levelid = 9
|
|
|
|
neighbor = _make_asset(assetid=2, mapx=10, mapy=20, levelid=2)
|
|
rel = _make_rel('partof', neighbor)
|
|
asset = _make_asset(assetid=1, levelid=7, outgoing=[rel],
|
|
location=FakeLocation())
|
|
|
|
result = resolve_asset_position(asset)
|
|
# Three levels are in play - the asset's own stale 7, the location's 9, and
|
|
# the neighbour's 2. Only the one that owns the coordinates is correct.
|
|
assert result == {'mapx': 10, 'mapy': 20, 'levelid': 2,
|
|
'positionsource': 'related'}
|
|
|
|
|
|
def test_resolve_asset_position_inactive_edge_skipped():
|
|
"""Soft-deleted (isactive=false) relationships are not walked."""
|
|
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
|
rel = _make_rel('partof', neighbor, isactive=False)
|
|
asset = _make_asset(assetid=1, outgoing=[rel])
|
|
|
|
assert resolve_asset_position(asset) is None
|
|
|
|
|
|
def test_plugin_get_setting_returns_default_when_unset(db, app):
|
|
"""A plugin reading an unset setting gets the default."""
|
|
from shopdb.plugins import plugin_manager
|
|
with app.app_context():
|
|
printers = plugin_manager.get_plugin('printers')
|
|
if printers is None:
|
|
pytest.skip('printers plugin not loaded')
|
|
assert printers.get_setting('nonexistentkey', default='fallback') == 'fallback'
|
|
|
|
|
|
def test_plugin_set_and_get_setting_roundtrip(db, app):
|
|
"""A plugin can set and read its own setting; key is namespaced."""
|
|
from shopdb.plugins import plugin_manager
|
|
with app.app_context():
|
|
printers = plugin_manager.get_plugin('printers')
|
|
if printers is None:
|
|
pytest.skip('printers plugin not loaded')
|
|
|
|
printers.set_setting('zabbix_url', 'http://zabbix.example.com')
|
|
|
|
assert printers.get_setting('zabbix_url') == 'http://zabbix.example.com'
|
|
|
|
raw = Setting.query.filter_by(key='plugin.printers.zabbix_url').first()
|
|
assert raw is not None
|
|
assert raw.value == 'http://zabbix.example.com'
|
|
|
|
|
|
def test_plugin_setting_is_namespaced_per_plugin(db, app):
|
|
"""Two plugins using the same key do not collide."""
|
|
from shopdb.plugins import plugin_manager
|
|
with app.app_context():
|
|
printers = plugin_manager.get_plugin('printers')
|
|
computers = plugin_manager.get_plugin('computers')
|
|
if printers is None or computers is None:
|
|
pytest.skip('required plugins not loaded')
|
|
|
|
printers.set_setting('shared_key', 'printers_value')
|
|
computers.set_setting('shared_key', 'computers_value')
|
|
|
|
assert printers.get_setting('shared_key') == 'printers_value'
|
|
assert computers.get_setting('shared_key') == 'computers_value'
|