Files
shopdb-flask/migrations/versions/7d33_buildings_and_levels.py
cproudlock 3324dbd91e
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
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.
2026-08-17 12:55:51 -04:00

214 lines
9.8 KiB
Python

"""Buildings and levels: a map is a drawing per level, not one image per site.
See ADR-017. `map_blueprint_light`, `map_blueprint_dark`, `map_width` and
`map_height` described one image for the whole site, and `assets.mapx`/`mapy`
were pixels in it. A second level and a likely second building make that a
table.
This migration is written so nothing renders differently the day it lands: the
four settings become one building and one level, marked default, and every asset
that has a position points at it. The settings rows are left in place here and
retired separately, so a rollback does not lose the blueprint paths.
`levelid` is nullable because an asset with no position needs no level. A
position WITHOUT a level is the case the UI refuses to guess about, and after
this migration no such row exists.
Revision ID: 7d33_buildings_and_levels
Revises: 7d32_displayrole_kiosk_vocabulary
"""
from alembic import op
import sqlalchemy as sa
revision = '7d33_buildings_and_levels'
down_revision = '7d32_displayrole_kiosk_vocabulary'
branch_labels = None
depends_on = None
# What the settings said before this table existed. Read at upgrade time; these
# are only the fallbacks for a site that never set them.
DEFAULT_WIDTH = 3300
DEFAULT_HEIGHT = 2550
PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
def _existing(insp, name):
return name in insp.get_table_names()
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
# Guarded like every other table-creating migration in this project: on a
# FRESH database the tables are built from the SQLAlchemy models, which
# already declare them, so an unconditional create fails.
if not _existing(insp, 'buildings'):
op.create_table(
'buildings',
sa.Column('buildingid', sa.Integer, primary_key=True),
sa.Column('buildingname', sa.String(100), nullable=False,
unique=True),
sa.Column('sortorder', sa.Integer, nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime, nullable=False),
sa.Column('modifieddate', sa.DateTime, nullable=False),
sa.Column('isactive', sa.Boolean, nullable=False,
server_default=sa.true()),
)
if not _existing(insp, 'maplevels'):
op.create_table(
'maplevels',
sa.Column('levelid', sa.Integer, primary_key=True),
sa.Column('buildingid', sa.Integer,
sa.ForeignKey('buildings.buildingid'), nullable=False,
index=True),
sa.Column('levelname', sa.String(100), nullable=False),
sa.Column('sortorder', sa.Integer, nullable=False,
server_default='0'),
sa.Column('blueprintlight', sa.String(255), nullable=True),
sa.Column('blueprintdark', sa.String(255), nullable=True),
sa.Column('mapwidth', sa.Integer, nullable=False,
server_default=str(DEFAULT_WIDTH)),
sa.Column('mapheight', sa.Integer, nullable=False,
server_default=str(DEFAULT_HEIGHT)),
sa.Column('isdefault', sa.Boolean, nullable=False,
server_default=sa.false()),
sa.Column('createddate', sa.DateTime, nullable=False),
sa.Column('modifieddate', sa.DateTime, nullable=False),
sa.Column('isactive', sa.Boolean, nullable=False,
server_default=sa.true()),
sa.UniqueConstraint('buildingid', 'levelname',
name='uq_maplevel_building_name'),
)
# Positions have no history, and a bulk transform rewrites hundreds of them
# at once. A snapshot table is what makes that reversible - without it, the
# honest advice would be "back up the database first", which nobody does
# before a UI action.
if not _existing(insp, 'mappositionsnapshots'):
op.create_table(
'mappositionsnapshots',
sa.Column('snapshotid', sa.Integer, primary_key=True),
sa.Column('levelid', sa.Integer, nullable=True),
sa.Column('reason', sa.String(255), nullable=True),
sa.Column('assetcount', sa.Integer, nullable=False,
server_default='0'),
# The positions themselves, as JSON: assetid, mapx, mapy, levelid,
# mapverifiedat per row. Deliberately not a child table - a snapshot
# is read back whole or not at all, and one row per snapshot keeps
# restore a single statement.
sa.Column('positionsjson', sa.Text, nullable=False),
sa.Column('restoredat', sa.DateTime, nullable=True),
sa.Column('createdby', sa.String(100), nullable=True),
sa.Column('createddate', sa.DateTime, nullable=False),
sa.Column('modifieddate', sa.DateTime, nullable=False),
sa.Column('isactive', sa.Boolean, nullable=False,
server_default=sa.true()),
)
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
if 'levelid' not in assetcolumns:
op.add_column('assets', sa.Column('levelid', sa.Integer, nullable=True))
op.create_index('idx_assets_levelid', 'assets', ['levelid'])
# The FK is added separately from the column so a site whose assets
# table is large is not rewritten twice.
op.create_foreign_key('fk_assets_levelid', 'assets', 'maplevels',
['levelid'], ['levelid'])
if 'mapverifiedat' not in assetcolumns:
op.add_column('assets',
sa.Column('mapverifiedat', sa.DateTime, nullable=True))
# Locations carry map coordinates too - they are the default position for
# assets at that location - so they need a level for exactly the same
# reason. Missed on the first pass and caught by the payload gate.
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
if 'levelid' not in locationcolumns:
op.add_column('locations', sa.Column('levelid', sa.Integer, nullable=True))
op.create_index('idx_locations_levelid', 'locations', ['levelid'])
op.create_foreign_key('fk_locations_levelid', 'locations', 'maplevels',
['levelid'], ['levelid'])
# --- carry the settings forward -------------------------------------
# Only when there is nothing here yet: re-running must not create a second
# default level, and a site that has already set its levels up must not have
# them joined by a stale one built from retired settings.
existinglevels = bind.execute(
sa.text('SELECT COUNT(*) FROM maplevels')).scalar() or 0
if existinglevels:
return
settings = dict(bind.execute(sa.text(
"SELECT `key`, value FROM settings WHERE `key` IN "
"('map_blueprint_light','map_blueprint_dark','map_width','map_height')"
)).fetchall())
def _int(value, fallback):
try:
number = int(str(value).strip())
return number if number > 0 else fallback
except (TypeError, ValueError):
return fallback
bind.execute(sa.text(
'INSERT INTO buildings (buildingname, sortorder, createddate, '
'modifieddate, isactive) VALUES (:name, 0, :now, :now, :active)'),
{'name': 'Main', 'now': sa.func.now(), 'active': True})
buildingid = bind.execute(sa.text(
'SELECT buildingid FROM buildings WHERE buildingname = :name'),
{'name': 'Main'}).scalar()
bind.execute(sa.text(
'INSERT INTO maplevels (buildingid, levelname, sortorder, '
'blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, '
'createddate, modifieddate, isactive) VALUES (:building, :name, 0, '
':light, :dark, :width, :height, :isdefault, :now, :now, :active)'),
{'building': buildingid,
'name': 'Ground floor',
'light': settings.get('map_blueprint_light') or PLACEHOLDER,
'dark': settings.get('map_blueprint_dark') or PLACEHOLDER,
'width': _int(settings.get('map_width'), DEFAULT_WIDTH),
'height': _int(settings.get('map_height'), DEFAULT_HEIGHT),
'isdefault': True, 'now': sa.func.now(), 'active': True})
levelid = bind.execute(sa.text(
'SELECT levelid FROM maplevels WHERE isdefault = :flag'),
{'flag': True}).scalar()
# Every asset that already has a position had it in this one drawing's
# coordinate space, so it belongs to this level. An asset with no position
# is left null: it needs no level until somebody places it.
bind.execute(sa.text(
'UPDATE assets SET levelid = :levelid '
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
{'levelid': levelid})
bind.execute(sa.text(
'UPDATE locations SET levelid = :levelid '
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
{'levelid': levelid})
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
if 'levelid' in assetcolumns:
op.drop_constraint('fk_assets_levelid', 'assets', type_='foreignkey')
op.drop_index('idx_assets_levelid', table_name='assets')
op.drop_column('assets', 'levelid')
if 'mapverifiedat' in assetcolumns:
op.drop_column('assets', 'mapverifiedat')
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
if 'levelid' in locationcolumns:
op.drop_constraint('fk_locations_levelid', 'locations', type_='foreignkey')
op.drop_index('idx_locations_levelid', table_name='locations')
op.drop_column('locations', 'levelid')
if _existing(insp, 'mappositionsnapshots'):
op.drop_table('mappositionsnapshots')
if _existing(insp, 'maplevels'):
op.drop_table('maplevels')
if _existing(insp, 'buildings'):
op.drop_table('buildings')