Buildings and levels for the floor map, and make every identifier searchable
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.
This commit is contained in:
@@ -8,6 +8,8 @@ from .vendors import vendors_bp
|
||||
from .models import models_bp
|
||||
from .businessunits import businessunits_bp
|
||||
from .locations import locations_bp
|
||||
from .maplevels import maplevels_bp
|
||||
from .mappositions import mappositions_bp
|
||||
from .operatingsystems import operatingsystems_bp
|
||||
from .dashboard import dashboard_bp
|
||||
from .dashboarddefaults import dashboarddefaults_bp
|
||||
@@ -34,6 +36,8 @@ __all__ = [
|
||||
'models_bp',
|
||||
'businessunits_bp',
|
||||
'locations_bp',
|
||||
'maplevels_bp',
|
||||
'mappositions_bp',
|
||||
'operatingsystems_bp',
|
||||
'dashboard_bp',
|
||||
'dashboarddefaults_bp',
|
||||
|
||||
@@ -476,6 +476,7 @@ def create_asset():
|
||||
locationid=data.get('locationid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
mapx=data.get('mapx'),
|
||||
levelid=data.get('levelid'),
|
||||
mapy=data.get('mapy'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
@@ -517,7 +518,7 @@ def update_asset(asset_id: int):
|
||||
# Update allowed fields
|
||||
allowed_fields = [
|
||||
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'
|
||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive'
|
||||
]
|
||||
|
||||
for key in allowed_fields:
|
||||
@@ -1114,6 +1115,7 @@ def get_assets_map():
|
||||
'displayname': asset.display_name,
|
||||
'serialnumber': asset.serialnumber,
|
||||
'mapx': asset.mapx,
|
||||
'levelid': asset.levelid,
|
||||
'mapy': asset.mapy,
|
||||
'assettype': asset.assettype.assettype if asset.assettype else None,
|
||||
'assettypeid': asset.assettypeid,
|
||||
|
||||
350
shopdb/core/api/maplevels.py
Normal file
350
shopdb/core/api/maplevels.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Buildings and levels: the drawings a marker can be placed on (ADR-017).
|
||||
|
||||
Reads are UNAUTHENTICATED. The printer installer map runs before anyone logs in
|
||||
and needs a blueprint to draw, exactly as the printer install-list and the slide
|
||||
feed already do. A level name, a blueprint path and a pixel size are not
|
||||
secrets - the marker positions drawn on them already render on kiosk pages.
|
||||
|
||||
Writes require admin: adding a level changes where every marker on it appears.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Building, MapLevel, Asset, AuditLog
|
||||
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
|
||||
|
||||
maplevels_bp = Blueprint('maplevels', __name__)
|
||||
|
||||
# Same set the map blueprint upload already accepts. SVG stays allowed because a
|
||||
# floor plan is vector by nature; it is served through send_upload, which sends
|
||||
# the sandbox headers that stop one executing as script.
|
||||
BLUEPRINT_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||
|
||||
|
||||
def _blueprint_dir():
|
||||
return os.path.join(current_app.instance_path, 'maps')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Read - public
|
||||
# =============================================================================
|
||||
|
||||
@maplevels_bp.route('', methods=['GET'])
|
||||
def list_levels():
|
||||
"""Every active level, grouped by building, in display order.
|
||||
|
||||
One call, because every consumer needs the whole list: the map switches
|
||||
between levels, and a hover preview has to resolve an arbitrary asset's
|
||||
level to a blueprint without knowing in advance which one it is.
|
||||
"""
|
||||
buildings = (Building.query.filter_by(isactive=True)
|
||||
.order_by(Building.sortorder, Building.buildingid).all())
|
||||
|
||||
# How many markers sit on each level, in one grouped query rather than one
|
||||
# per level. The admin page needs it to say what a rename or a resize is
|
||||
# about to affect, and it is what makes the delete refusal predictable
|
||||
# instead of a surprise.
|
||||
counts = dict(db.session.query(Asset.levelid, db.func.count(Asset.assetid))
|
||||
.filter(Asset.levelid.isnot(None), Asset.isactive.is_(True))
|
||||
.group_by(Asset.levelid).all())
|
||||
|
||||
payload = []
|
||||
for building in buildings:
|
||||
entry = building.to_dict()
|
||||
for level in entry.get('levels', []):
|
||||
level['assetcount'] = counts.get(level['levelid'], 0)
|
||||
payload.append(entry)
|
||||
|
||||
default = MapLevel.default_level()
|
||||
return success_response({
|
||||
'buildings': payload,
|
||||
'defaultlevelid': default.levelid if default else None,
|
||||
'totalplaced': sum(counts.values()),
|
||||
})
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||
def get_level(levelid):
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level or not level.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
return success_response(level.to_dict())
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||
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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Write - admin
|
||||
# =============================================================================
|
||||
|
||||
@maplevels_bp.route('/buildings', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_building():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('buildingname') or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'buildingname is required')
|
||||
if Building.query.filter(db.func.lower(Building.buildingname)
|
||||
== name.lower()).first():
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Building '{name}' already exists",
|
||||
http_code=409)
|
||||
building = Building(buildingname=name,
|
||||
sortorder=int(data.get('sortorder') or 0))
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
AuditLog.log('created', 'Building', entityid=building.buildingid,
|
||||
entityname=name)
|
||||
db.session.commit()
|
||||
return success_response(building.to_dict(), message='Building created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@maplevels_bp.route('/buildings/<int:buildingid>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_building(buildingid):
|
||||
"""Rename or reorder a building. Levels move with it; nothing repositions."""
|
||||
building = db.session.get(Building, buildingid)
|
||||
if not building:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||
http_code=404)
|
||||
data = request.get_json() or {}
|
||||
if 'buildingname' in data:
|
||||
name = (data['buildingname'] or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'buildingname cannot be blank')
|
||||
clash = Building.query.filter(
|
||||
db.func.lower(Building.buildingname) == name.lower(),
|
||||
Building.buildingid != buildingid).first()
|
||||
if clash:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Building '{name}' already exists",
|
||||
http_code=409)
|
||||
building.buildingname = name
|
||||
if data.get('sortorder') is not None:
|
||||
building.sortorder = int(data['sortorder'])
|
||||
if 'isactive' in data:
|
||||
building.isactive = bool(data['isactive'])
|
||||
db.session.commit()
|
||||
AuditLog.log('updated', 'Building', entityid=buildingid,
|
||||
entityname=building.buildingname)
|
||||
db.session.commit()
|
||||
return success_response(building.to_dict(), message='Building updated')
|
||||
|
||||
|
||||
@maplevels_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_level():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('levelname') or '').strip()
|
||||
buildingid = data.get('buildingid')
|
||||
if not name or not buildingid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'buildingname and buildingid are required')
|
||||
if not db.session.get(Building, buildingid):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||
http_code=404)
|
||||
|
||||
level = MapLevel(
|
||||
buildingid=buildingid,
|
||||
levelname=name,
|
||||
sortorder=int(data.get('sortorder') or 0),
|
||||
blueprintlight=data.get('blueprintlight'),
|
||||
blueprintdark=data.get('blueprintdark'),
|
||||
mapwidth=int(data.get('mapwidth') or 3300),
|
||||
mapheight=int(data.get('mapheight') or 2550),
|
||||
)
|
||||
db.session.add(level)
|
||||
db.session.flush()
|
||||
_apply_default(level, data.get('isdefault'))
|
||||
db.session.commit()
|
||||
AuditLog.log('created', 'MapLevel', entityid=level.levelid,
|
||||
entityname=name)
|
||||
db.session.commit()
|
||||
return success_response(level.to_dict(), message='Level created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_level(levelid):
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
data = request.get_json() or {}
|
||||
|
||||
# The dimensions are the coordinate space every marker on this level is
|
||||
# expressed in, so changing them moves every marker relative to the image.
|
||||
# Report it rather than doing it silently; the caller decides whether to run
|
||||
# a transform (POST /api/assets/map/transform).
|
||||
warnings = []
|
||||
for field in ('mapwidth', 'mapheight'):
|
||||
if field in data and int(data[field] or 0) != getattr(level, field):
|
||||
warnings.append(
|
||||
'%s changed from %s to %s; existing positions on this level are '
|
||||
'still in the old coordinate space' % (field,
|
||||
getattr(level, field),
|
||||
data[field]))
|
||||
|
||||
for field in ('levelname', 'blueprintlight', 'blueprintdark'):
|
||||
if field in data:
|
||||
setattr(level, field, data[field])
|
||||
for field in ('sortorder', 'mapwidth', 'mapheight'):
|
||||
if field in data and data[field] is not None:
|
||||
setattr(level, field, int(data[field]))
|
||||
if 'isactive' in data:
|
||||
level.isactive = bool(data['isactive'])
|
||||
_apply_default(level, data.get('isdefault'))
|
||||
|
||||
db.session.commit()
|
||||
AuditLog.log('updated', 'MapLevel', entityid=level.levelid,
|
||||
entityname=level.levelname)
|
||||
db.session.commit()
|
||||
payload = level.to_dict()
|
||||
if warnings:
|
||||
payload['warnings'] = warnings
|
||||
return success_response(payload, message='Level updated')
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_level(levelid):
|
||||
"""Deactivate a level, refusing while assets are still placed on it.
|
||||
|
||||
Deleting the drawing out from under a marker would leave a position in a
|
||||
coordinate space that no longer exists - unrenderable, and indistinguishable
|
||||
from a marker that was never placed.
|
||||
"""
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||
if placed:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f'{placed} asset(s) are placed on this level. Move them to another '
|
||||
f'level first.', http_code=409)
|
||||
if level.isdefault:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'This is the default level. Make another level the default first.')
|
||||
level.isactive = False
|
||||
db.session.commit()
|
||||
AuditLog.log('deleted', 'MapLevel', entityid=levelid,
|
||||
entityname=level.levelname)
|
||||
db.session.commit()
|
||||
return success_response(message='Level deactivated')
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>/blueprint', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def upload_blueprint(levelid):
|
||||
"""Upload this level's blueprint for one theme.
|
||||
|
||||
multipart/form-data: file=<image>, theme=light|dark. The native pixel size
|
||||
is NOT inferred from the image - it is stated on the level, because that is
|
||||
what existing coordinates mean and guessing it would move every marker.
|
||||
"""
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
theme = (request.form.get('theme') or '').strip().lower()
|
||||
if theme not in ('light', 'dark'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'theme must be light or dark')
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in BLUEPRINT_EXTENSIONS:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unsupported image type {ext}')
|
||||
|
||||
os.makedirs(_blueprint_dir(), exist_ok=True)
|
||||
filename = secure_filename(f'level-{levelid}-{theme}{ext}')
|
||||
raw = upload.read()
|
||||
upload.seek(0)
|
||||
upload.save(os.path.join(_blueprint_dir(), filename))
|
||||
url = f'/api/maplevels/{levelid}/blueprint/{filename}'
|
||||
setattr(level, f'blueprint{theme}', url)
|
||||
|
||||
# The image's real pixel size, read from its header. What happens next
|
||||
# depends entirely on whether anything is already placed on this level.
|
||||
detectedwidth, detectedheight = image_size(raw)
|
||||
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||
sizenote = None
|
||||
if detectedwidth and detectedheight:
|
||||
matches = (detectedwidth == level.mapwidth
|
||||
and detectedheight == level.mapheight)
|
||||
if matches:
|
||||
sizenote = None
|
||||
elif not placed:
|
||||
# Nothing is placed here yet, so no coordinate can be invalidated:
|
||||
# adopt the image's own size, which is almost certainly what the
|
||||
# operator wanted and saves them typing it.
|
||||
level.mapwidth = detectedwidth
|
||||
level.mapheight = detectedheight
|
||||
sizenote = ('dimensions set from the image: %d x %d'
|
||||
% (detectedwidth, detectedheight))
|
||||
else:
|
||||
# Markers exist in the OLD coordinate space. Silently adopting the
|
||||
# new size would move every one of them relative to the drawing
|
||||
# while looking like a successful upload, so this reports and
|
||||
# changes nothing. Resizing is a transform, not an upload.
|
||||
sizenote = (
|
||||
'this image is %d x %d but the level is set to %d x %d, and %d '
|
||||
'marker(s) are placed in the current space. The dimensions were '
|
||||
'NOT changed: set them with a landmark transform '
|
||||
'(POST /api/mappositions/transform) so the markers move with '
|
||||
'them.' % (detectedwidth, detectedheight, level.mapwidth,
|
||||
level.mapheight, placed))
|
||||
|
||||
db.session.commit()
|
||||
AuditLog.log('updated', 'MapLevel', entityid=levelid,
|
||||
entityname=level.levelname,
|
||||
changes={f'blueprint{theme}': {'new': url}})
|
||||
db.session.commit()
|
||||
payload = {'levelid': levelid, f'blueprint{theme}': url,
|
||||
'mapwidth': level.mapwidth, 'mapheight': level.mapheight,
|
||||
'detectedwidth': detectedwidth, 'detectedheight': detectedheight,
|
||||
'placedassets': placed}
|
||||
if sizenote:
|
||||
payload['sizenote'] = sizenote
|
||||
return success_response(payload, message='Blueprint uploaded')
|
||||
|
||||
|
||||
def _apply_default(level, requested):
|
||||
"""Make this level the default, clearing the flag elsewhere.
|
||||
|
||||
Exactly-one-default is a rule across rows, which no column constraint can
|
||||
express, so it is enforced here - the one place that sets the flag.
|
||||
"""
|
||||
if not requested:
|
||||
return
|
||||
MapLevel.query.filter(MapLevel.levelid != level.levelid).update(
|
||||
{'isdefault': False})
|
||||
level.isdefault = True
|
||||
403
shopdb/core/api/mappositions.py
Normal file
403
shopdb/core/api/mappositions.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""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 ''))
|
||||
@@ -236,13 +236,26 @@ def _search_applications(query, search_term):
|
||||
|
||||
|
||||
def _search_knowledgebase(query, search_term):
|
||||
"""Search Knowledge Base by description and keywords."""
|
||||
"""Search Knowledge Base by description and keywords.
|
||||
|
||||
An article whose topic is a RETIRED application is excluded, matching
|
||||
GET /api/knowledgebase. Filtering it out of the plugin's own listing while
|
||||
global search still returned it is not a rule at all: the article was two
|
||||
keystrokes away, and the result printed the retired application's name as its
|
||||
subject, which reads as though it were still in service.
|
||||
|
||||
A null topic still matches. Not every article is about an application.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
_require_enabled('knowledgebase')
|
||||
from plugins.knowledgebase.models import KnowledgeBase
|
||||
retired = db.session.query(Application.appid).filter(
|
||||
Application.isactive.is_(False))
|
||||
kb_articles = KnowledgeBase.query.filter(
|
||||
KnowledgeBase.isactive == True,
|
||||
db.or_(KnowledgeBase.appid.is_(None),
|
||||
KnowledgeBase.appid.notin_(retired)),
|
||||
_word_match(query, KnowledgeBase.shortdescription,
|
||||
KnowledgeBase.keywords)
|
||||
).limit(20).all()
|
||||
@@ -339,7 +352,22 @@ def _search_employees(query, search_term):
|
||||
|
||||
|
||||
def _search_assets(query, search_term):
|
||||
"""Search unified Assets table by number, name, serial, notes."""
|
||||
"""Search unified Assets table by number, name, serial, notes and the two
|
||||
optional identifiers.
|
||||
|
||||
gaugelabreference and maintenancereference are searched for EVERY asset type
|
||||
(ADR-001). Settings lets a site enable either identifier on machines, PCs,
|
||||
printers and network devices, but only the measuring-tools searcher looked at
|
||||
gaugelabreference and nothing looked at maintenancereference at all - so a
|
||||
tag an operator was told to record was one nobody could search by. An
|
||||
identifier that can be entered has to be findable, or it is a write-only
|
||||
field.
|
||||
|
||||
The per-type `identifier_<name>_<assettype>_enabled` toggles are NOT applied
|
||||
here. They govern whether the field is SHOWN on that type; a value already in
|
||||
the row is still the tag written on the physical machine, and matching it is
|
||||
strictly better than returning nothing to someone reading it off a label.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
assets = Asset.query.join(AssetType).options(
|
||||
@@ -348,7 +376,8 @@ def _search_assets(query, search_term):
|
||||
).filter(
|
||||
Asset.isactive == True,
|
||||
_word_match(query, Asset.assetnumber, Asset.name,
|
||||
Asset.serialnumber, Asset.notes)
|
||||
Asset.serialnumber, Asset.notes,
|
||||
Asset.gaugelabreference, Asset.maintenancereference)
|
||||
).limit(15).all()
|
||||
|
||||
for asset in assets:
|
||||
@@ -357,6 +386,10 @@ def _search_assets(query, search_term):
|
||||
relevance = 100
|
||||
elif asset.name and query.lower() == asset.name.lower():
|
||||
relevance = 90
|
||||
elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower():
|
||||
relevance = 88
|
||||
elif asset.maintenancereference and query.lower() == asset.maintenancereference.lower():
|
||||
relevance = 86
|
||||
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
|
||||
relevance = 85
|
||||
elif asset.name and query.lower() in asset.name.lower():
|
||||
@@ -408,6 +441,104 @@ def _search_measuringtools(query, search_term):
|
||||
return results
|
||||
|
||||
|
||||
def _search_usbdevices(query, search_term):
|
||||
"""Search USB devices by serial, asset tag and product name.
|
||||
|
||||
A USB device is NOT an asset - it lives in the usb plugin's own table - so
|
||||
the generic asset search cannot see it and these records were unreachable
|
||||
from search entirely. Serial number is the field people actually have in
|
||||
hand: it is what is printed on the stick they are holding.
|
||||
|
||||
currentusername is deliberately NOT searched. It records who holds the
|
||||
device, and making search a way to list what a named person has checked out
|
||||
is a different feature from finding a device.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
_require_enabled('usb')
|
||||
from plugins.usb.models import USBDevice
|
||||
devices = USBDevice.query.filter(
|
||||
USBDevice.isactive == True,
|
||||
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
||||
USBDevice.label, USBDevice.productname)
|
||||
).limit(10).all()
|
||||
|
||||
for device in devices:
|
||||
relevance = 20
|
||||
if query.lower() == (device.serialnumber or '').lower():
|
||||
relevance = 100
|
||||
elif query.lower() == (device.assetnumber or '').lower():
|
||||
relevance = 90
|
||||
elif query.lower() == (device.label or '').lower():
|
||||
relevance = 85
|
||||
elif query.lower() in (device.label or '').lower():
|
||||
relevance = 50
|
||||
elif query.lower() in (device.productname or '').lower():
|
||||
relevance = 40
|
||||
|
||||
results.append({
|
||||
'type': 'usb_device',
|
||||
'id': device.usbdeviceid,
|
||||
'title': device.label or device.productname or device.serialnumber,
|
||||
'subtitle': device.assetnumber or device.serialnumber,
|
||||
'url': f'/usb/{device.usbdeviceid}',
|
||||
'relevance': relevance,
|
||||
})
|
||||
except ImportError:
|
||||
pass # usb plugin absent or disabled
|
||||
except Exception as e:
|
||||
logger.error(f"USB device search failed: {e}")
|
||||
return results
|
||||
|
||||
|
||||
def _search_printeditems(query, search_term):
|
||||
"""Search printed items by bin code, gage-lab tag, name and description.
|
||||
|
||||
Printed items are their own records, not assets, so the generic asset search
|
||||
never covered them. itemcode (the bin label, e.g. 3DP-0042) and gagelabtag
|
||||
are both unique and both printed on physical labels, which makes them the
|
||||
likeliest thing anyone types into search.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
_require_enabled('printedparts')
|
||||
from plugins.printedparts.models import PrintedItem
|
||||
items = PrintedItem.query.filter(
|
||||
PrintedItem.isactive == True,
|
||||
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
|
||||
PrintedItem.itemname, PrintedItem.itemdescription)
|
||||
).limit(10).all()
|
||||
|
||||
for item in items:
|
||||
relevance = 20
|
||||
if query.lower() == (item.itemcode or '').lower():
|
||||
relevance = 100
|
||||
elif query.lower() == (item.gagelabtag or '').lower():
|
||||
relevance = 95
|
||||
elif query.lower() == (item.itemname or '').lower():
|
||||
relevance = 90
|
||||
elif query.lower() in (item.itemname or '').lower():
|
||||
relevance = 50
|
||||
|
||||
subtitle = item.itemcode or item.gagelabtag
|
||||
if item.binlocation:
|
||||
subtitle = f'{subtitle} - {item.binlocation}' if subtitle else item.binlocation
|
||||
|
||||
results.append({
|
||||
'type': 'printed_item',
|
||||
'id': item.printeditemid,
|
||||
'title': item.itemname,
|
||||
'subtitle': subtitle,
|
||||
'url': f'/printedparts/{item.printeditemid}',
|
||||
'relevance': relevance,
|
||||
})
|
||||
except ImportError:
|
||||
pass # printedparts plugin absent or disabled
|
||||
except Exception as e:
|
||||
logger.error(f"Printed item search failed: {e}")
|
||||
return results
|
||||
|
||||
|
||||
def _search_customfields(query, search_term):
|
||||
"""Search custom-field VALUES for fields flagged searchable.
|
||||
|
||||
@@ -886,6 +1017,8 @@ def global_search():
|
||||
results.extend(_search_employees(query, search_term))
|
||||
results.extend(_search_assets(query, search_term))
|
||||
results.extend(_search_measuringtools(query, search_term))
|
||||
results.extend(_search_usbdevices(query, search_term))
|
||||
results.extend(_search_printeditems(query, search_term))
|
||||
results.extend(_search_customfields(query, search_term))
|
||||
results.extend(_search_notifications(query, search_term))
|
||||
results.extend(_search_hostnames(query, search_term))
|
||||
|
||||
@@ -109,6 +109,8 @@ SEARCH_DOMAINS = {
|
||||
'measuring_tool': 'Measuring Tools',
|
||||
'notification': 'Notifications',
|
||||
'subnet': 'Subnets',
|
||||
'usb_device': 'USB Devices',
|
||||
'printed_item': 'Printed Items',
|
||||
}
|
||||
|
||||
def _declared_default(key: str) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user