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.
404 lines
16 KiB
Python
404 lines
16 KiB
Python
"""Bulk marker positions: transform, place, verify, undo (ADR-017).
|
|
|
|
A new blueprint invalidates every marker on a level at once, so the operations
|
|
here are deliberately bulk. Three rules shape all of them:
|
|
|
|
**A transform is derived from landmarks, never from image sizes.** The real case
|
|
that motivated this is 3300x2550 to 3308x4000, where the second level was added
|
|
below the first: the correct transform is identity scale with a Y offset, and a
|
|
scale derived from the dimension ratio would stretch every Y by 1.57 and be wrong
|
|
everywhere. Dimensions describe the canvas; landmarks describe the drawing.
|
|
|
|
**A transform is a guess, so it clears the review state.** The levels were
|
|
redrawn and machines moved. Nothing in a coordinate says whether the machine it
|
|
points at is still there, so every transformed marker is unreviewed until a human
|
|
confirms it.
|
|
|
|
**Nothing bulk happens without a snapshot.** Positions have no history, and an
|
|
operation that rewrites hundreds of rows needs a way back that is not a database
|
|
restore.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import (Asset, AuditLog, MapLevel,
|
|
MapPositionSnapshot, User)
|
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
|
from shopdb.utils.authz import require_permission
|
|
|
|
mappositions_bp = Blueprint('mappositions', __name__)
|
|
|
|
|
|
def _now():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def _actor():
|
|
try:
|
|
user = db.session.get(User, int(get_jwt_identity()))
|
|
return user.username if user else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# The transform
|
|
# =============================================================================
|
|
|
|
def solve_axis(pairs):
|
|
"""Least-squares scale and offset for one axis: new = scale * old + offset.
|
|
|
|
Two pairs solve it exactly; more are averaged, which is worth having because
|
|
a landmark picked by eye on a scanned drawing carries a few pixels of error
|
|
and three points let that cancel instead of accumulate.
|
|
|
|
Returns None when the landmarks cannot determine the axis - every old value
|
|
identical, so the denominator is zero. That is a real user error (two
|
|
landmarks on the same column) and it must be reported rather than papered
|
|
over with a scale of 1, which would look like it worked.
|
|
"""
|
|
count = len(pairs)
|
|
if count < 2:
|
|
return None
|
|
sumold = sum(old for old, _ in pairs)
|
|
sumnew = sum(new for _, new in pairs)
|
|
sumoldsq = sum(old * old for old, _ in pairs)
|
|
sumcross = sum(old * new for old, new in pairs)
|
|
denominator = count * sumoldsq - sumold * sumold
|
|
if abs(denominator) < 1e-9:
|
|
return None
|
|
scale = (count * sumcross - sumold * sumnew) / denominator
|
|
offset = (sumnew - scale * sumold) / count
|
|
return scale, offset
|
|
|
|
|
|
def derive_transform(landmarks):
|
|
"""Per-axis transform from [{'fromx','fromy','tox','toy'}, ...].
|
|
|
|
Per-axis rather than uniform on purpose. A level added below another changes
|
|
the canvas height without rescaling anything, so Y gets an offset and X gets
|
|
neither; forcing one scale onto both axes cannot express that.
|
|
"""
|
|
try:
|
|
xpairs = [(float(mark['fromx']), float(mark['tox'])) for mark in landmarks]
|
|
ypairs = [(float(mark['fromy']), float(mark['toy'])) for mark in landmarks]
|
|
except (KeyError, TypeError, ValueError):
|
|
return None, 'each landmark needs numeric fromx, fromy, tox and toy'
|
|
|
|
if len(landmarks) < 2:
|
|
return None, 'at least two landmarks are required'
|
|
|
|
xsolution = solve_axis(xpairs)
|
|
ysolution = solve_axis(ypairs)
|
|
if xsolution is None:
|
|
return None, ('the landmarks do not vary in X, so no horizontal scale '
|
|
'can be derived - pick points that differ across the drawing')
|
|
if ysolution is None:
|
|
return None, ('the landmarks do not vary in Y, so no vertical scale '
|
|
'can be derived - pick points that differ down the drawing')
|
|
|
|
return {
|
|
'scalex': xsolution[0], 'offsetx': xsolution[1],
|
|
'scaley': ysolution[0], 'offsety': ysolution[1],
|
|
}, None
|
|
|
|
|
|
def apply_transform(asset, transform):
|
|
return (
|
|
int(round(asset.mapx * transform['scalex'] + transform['offsetx'])),
|
|
int(round(asset.mapy * transform['scaley'] + transform['offsety'])),
|
|
)
|
|
|
|
|
|
@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.
|
|
|
|
Body:
|
|
levelid the level whose markers are being moved (required)
|
|
landmarks [{fromx, fromy, tox, toy}, ...] - two or more points, each the
|
|
same physical feature on the old drawing and the new one
|
|
tolevelid optional: write the results to a different level, for splitting
|
|
one drawing into two
|
|
assetids optional: restrict to these assets
|
|
dryrun default TRUE. Nothing is written unless this is explicitly false.
|
|
|
|
Dry run returns every marker's old and new position and whether it lands
|
|
outside the target level, which is the only way to see a bad landmark pair
|
|
before it has moved 300 markers.
|
|
"""
|
|
data = request.get_json() or {}
|
|
levelid = data.get('levelid')
|
|
level = db.session.get(MapLevel, levelid) if levelid else None
|
|
if not level:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'levelid is required and must exist')
|
|
|
|
target = level
|
|
if data.get('tolevelid'):
|
|
target = db.session.get(MapLevel, data['tolevelid'])
|
|
if not target:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such tolevelid',
|
|
http_code=404)
|
|
|
|
transform, problem = derive_transform(data.get('landmarks') or [])
|
|
if problem:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
|
|
|
query = Asset.query.filter(
|
|
Asset.levelid == level.levelid,
|
|
Asset.mapx.isnot(None), Asset.mapy.isnot(None),
|
|
Asset.isactive.is_(True))
|
|
if data.get('assetids'):
|
|
query = query.filter(Asset.assetid.in_(data['assetids']))
|
|
assets = query.order_by(Asset.assetid).all()
|
|
|
|
moves = []
|
|
outofbounds = 0
|
|
for asset in assets:
|
|
newx, newy = apply_transform(asset, transform)
|
|
outside = not (0 <= newx <= target.mapwidth and
|
|
0 <= newy <= target.mapheight)
|
|
if outside:
|
|
outofbounds += 1
|
|
moves.append({
|
|
'assetid': asset.assetid,
|
|
'assetnumber': asset.assetnumber,
|
|
'name': asset.name,
|
|
'fromx': asset.mapx, 'fromy': asset.mapy,
|
|
'tox': newx, 'toy': newy,
|
|
'outofbounds': outside,
|
|
})
|
|
|
|
# The derived transform is reported back whichever mode this is, because it
|
|
# is the number a human can sanity-check: a Y scale of 1.57 on a level that
|
|
# only grew taller is the mistake this endpoint exists to avoid, and it is
|
|
# obvious in the response and invisible in the result.
|
|
payload = {
|
|
'transform': transform,
|
|
'levelid': level.levelid,
|
|
'tolevelid': target.levelid,
|
|
'assetcount': len(moves),
|
|
'outofboundscount': outofbounds,
|
|
'moves': moves,
|
|
'dryrun': True,
|
|
}
|
|
|
|
if data.get('dryrun', True):
|
|
return success_response(payload)
|
|
|
|
snapshot = MapPositionSnapshot.capture(
|
|
assets,
|
|
reason='transform of %s (%d markers)' % (level.levelname, len(moves)),
|
|
levelid=level.levelid, createdby=_actor())
|
|
|
|
for asset, move in zip(assets, moves):
|
|
asset.mapx = move['tox']
|
|
asset.mapy = move['toy']
|
|
asset.levelid = target.levelid
|
|
# Cleared, not preserved: a transformed position is a guess, and the
|
|
# whole point of the review pass is to tell guesses from confirmations.
|
|
asset.mapverifiedat = None
|
|
db.session.commit()
|
|
|
|
AuditLog.log('updated', 'Asset', entityname='%d marker(s) transformed'
|
|
% len(moves),
|
|
changes={'transform': transform,
|
|
'snapshotid': snapshot.snapshotid})
|
|
db.session.commit()
|
|
|
|
payload['dryrun'] = False
|
|
payload['snapshotid'] = snapshot.snapshotid
|
|
return success_response(payload, message='%d marker(s) moved; snapshot %d '
|
|
'can restore them' % (len(moves),
|
|
snapshot.snapshotid))
|
|
|
|
|
|
# =============================================================================
|
|
# Bulk place and verify
|
|
# =============================================================================
|
|
|
|
@mappositions_bp.route('/positions', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('assets.edit')
|
|
def set_positions():
|
|
"""Set positions for many assets at once.
|
|
|
|
Body: {positions: [{assetid, mapx, mapy, levelid}], verified: bool}
|
|
|
|
`levelid` is required per position rather than taken from a single body-level
|
|
value, because a bulk save from the editor can legitimately span levels, and
|
|
inferring it would be the guess this whole feature exists to remove.
|
|
"""
|
|
data = request.get_json() or {}
|
|
rows = data.get('positions') or []
|
|
if not rows:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'positions is required and must not be empty')
|
|
|
|
wanted = {}
|
|
for row in rows:
|
|
assetid = row.get('assetid')
|
|
levelid = row.get('levelid')
|
|
if not assetid or not levelid:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'every position needs an assetid and a levelid; a position '
|
|
'without a level cannot be rendered')
|
|
if row.get('mapx') is None or row.get('mapy') is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'every position needs mapx and mapy')
|
|
wanted[int(assetid)] = row
|
|
|
|
levelids = {int(row['levelid']) for row in wanted.values()}
|
|
known = {level.levelid for level in
|
|
MapLevel.query.filter(MapLevel.levelid.in_(levelids)).all()}
|
|
missing = levelids - known
|
|
if missing:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'unknown levelid(s): %s'
|
|
% ', '.join(str(one) for one in sorted(missing)),
|
|
http_code=404)
|
|
|
|
assets = Asset.query.filter(Asset.assetid.in_(wanted)).all()
|
|
found = {asset.assetid for asset in assets}
|
|
unknown = set(wanted) - found
|
|
if unknown:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'unknown assetid(s): %s'
|
|
% ', '.join(str(one) for one in sorted(unknown)),
|
|
http_code=404)
|
|
|
|
snapshot = MapPositionSnapshot.capture(
|
|
assets, reason='bulk position set (%d markers)' % len(assets),
|
|
createdby=_actor())
|
|
|
|
verified = _now() if data.get('verified') else None
|
|
for asset in assets:
|
|
row = wanted[asset.assetid]
|
|
asset.mapx = int(row['mapx'])
|
|
asset.mapy = int(row['mapy'])
|
|
asset.levelid = int(row['levelid'])
|
|
# Placing a marker by hand IS the confirmation, so this stamps it. A
|
|
# caller that is only nudging things about can pass verified=false.
|
|
if verified or data.get('verified') is not False:
|
|
asset.mapverifiedat = verified or _now()
|
|
db.session.commit()
|
|
|
|
AuditLog.log('updated', 'Asset',
|
|
entityname='%d marker position(s) set' % len(assets),
|
|
changes={'snapshotid': snapshot.snapshotid})
|
|
db.session.commit()
|
|
return success_response({'updated': len(assets),
|
|
'snapshotid': snapshot.snapshotid},
|
|
message='%d position(s) saved' % len(assets))
|
|
|
|
|
|
@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.
|
|
|
|
This is the common case in a review pass: the transform put it in the right
|
|
place and a human agrees. No snapshot, because nothing about the position
|
|
changes - only the statement that somebody looked.
|
|
"""
|
|
data = request.get_json() or {}
|
|
assetids = data.get('assetids') or []
|
|
if not assetids:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'assetids is required')
|
|
stamp = None if data.get('unverify') else _now()
|
|
updated = (Asset.query.filter(Asset.assetid.in_(assetids))
|
|
.update({'mapverifiedat': stamp}, synchronize_session=False))
|
|
db.session.commit()
|
|
return success_response({'updated': updated},
|
|
message='%d marker(s) %s' %
|
|
(updated, 'unverified' if stamp is None
|
|
else 'marked reviewed'))
|
|
|
|
|
|
# =============================================================================
|
|
# Undo
|
|
# =============================================================================
|
|
|
|
@mappositions_bp.route('/snapshots', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('assets.view')
|
|
def list_snapshots():
|
|
"""Snapshots, newest first. Metadata only - the positions are large."""
|
|
snapshots = (MapPositionSnapshot.query
|
|
.order_by(MapPositionSnapshot.snapshotid.desc())
|
|
.limit(50).all())
|
|
return success_response([one.to_dict() for one in 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.
|
|
|
|
Takes its own snapshot first, so an undo is itself undoable - which matters
|
|
because the most likely reason to restore is a transform that looked right in
|
|
the preview and wrong on the drawing, and the second attempt is rarely the
|
|
last one either.
|
|
"""
|
|
snapshot = db.session.get(MapPositionSnapshot, snapshotid)
|
|
if not snapshot:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such snapshot',
|
|
http_code=404)
|
|
|
|
rows = snapshot.positions
|
|
if not rows:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'this snapshot holds no positions')
|
|
|
|
assetids = [row['assetid'] for row in rows]
|
|
assets = {asset.assetid: asset for asset in
|
|
Asset.query.filter(Asset.assetid.in_(assetids)).all()}
|
|
|
|
MapPositionSnapshot.capture(
|
|
list(assets.values()),
|
|
reason='before restoring snapshot %d' % snapshotid,
|
|
levelid=snapshot.levelid, createdby=_actor())
|
|
|
|
restored = 0
|
|
skipped = []
|
|
for row in rows:
|
|
asset = assets.get(row['assetid'])
|
|
if asset is None:
|
|
# The asset was deleted since the snapshot. Say so rather than
|
|
# failing the whole restore: the other 299 markers still want
|
|
# putting back.
|
|
skipped.append(row['assetid'])
|
|
continue
|
|
asset.mapx = row.get('mapx')
|
|
asset.mapy = row.get('mapy')
|
|
asset.levelid = row.get('levelid')
|
|
stamp = row.get('mapverifiedat')
|
|
asset.mapverifiedat = datetime.fromisoformat(stamp) if stamp else None
|
|
restored += 1
|
|
|
|
snapshot.restoredat = _now()
|
|
db.session.commit()
|
|
|
|
AuditLog.log('updated', 'Asset',
|
|
entityname='%d marker(s) restored from snapshot %d'
|
|
% (restored, snapshotid),
|
|
changes={'snapshotid': snapshotid, 'skipped': skipped})
|
|
db.session.commit()
|
|
|
|
return success_response(
|
|
{'restored': restored, 'skippedassetids': skipped},
|
|
message='%d marker(s) restored%s' %
|
|
(restored, '; %d asset(s) no longer exist' % len(skipped)
|
|
if skipped else ''))
|