The machine maintenance panel showed every date one calendar day early on this
site, and would show it a day late east of UTC.
lastmaintenancedate and nextmaintenancedate were DATETIME columns holding
midnight, and BaseModel.to_dict serialises a datetime as isoformat + 'Z', so
2026-12-15 00:00:00 went out as "2026-12-15T00:00:00Z" and the browser
converted it into local time. There is no time in a maintenance date: the form
offers a date picker, the CSV importer takes '2026-06-01', and the value is only
ever compared against other dates. Every sibling already models it correctly -
measuringtools.lastcalibrationdate, warranties.startdate and enddate are all
db.Date - so machines were the odd one out.
Migration 7d40 converts both columns; MySQL truncates a midnight time so no
data moves, and SQLite neither enforces nor needs it.
That exposed two more things. The route was assigning the raw request string to
the column, which a DATE column rejects outright - it now goes through the
strict parser from wave 2, so a mistyped maintenance date is refused with a 400
rather than stored or nulled. And BaseModel.to_dict had no branch for a plain
date, so one reached Flask's encoder and came out as an HTTP-date
("Thu, 15 Jan 2026 00:00:00 GMT") - which a browser parses back into a moment
and can still render as the day before. Dates now serialise as "2026-01-15",
with datetime checked first because it is a subclass of date.
Verified against MySQL on a live instance: the column is DATE, the API serves
2026-01-15, a mistyped date is refused with 400, and a rename on a machine that
has maintenance dates saves - the 500 from the audit-log fix in 5d81f50c, now
confirmed end to end rather than only in tests.
Scope: product
277 lines
11 KiB
Python
277 lines
11 KiB
Python
"""Dualpath single-machine collapse (site setting dualpath_single_machine).
|
|
|
|
A Dualpath pair is one physical dual-bay machine recorded as two asset rows.
|
|
With the setting on (default), the machines list, dashboard machine count, and
|
|
the floor map collapse the pair to the PRIMARY bay (lower natural-sort
|
|
assetnumber) and annotate it with its hidden partner. With the setting off,
|
|
both bays list and count separately. The data model always keeps both rows.
|
|
|
|
Settings are cached, so each test invalidates the cache after seeding.
|
|
"""
|
|
|
|
from shopdb.extensions import db as _db
|
|
from shopdb.core.models import Asset, AssetType, Setting
|
|
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
|
from shopdb.core.api.settings import invalidate_settings_cache
|
|
|
|
from plugins.machines.models import Machine
|
|
|
|
|
|
def _seed_dualpath_pair():
|
|
"""Create a 'machine' AssetType, two machine assets (2007, 2008) placed on
|
|
the map, and an active Dualpath relationship between them. 2007 is PRIMARY
|
|
(lower natural-sort). Returns (primary_machine, secondary_machine)."""
|
|
machinetype = AssetType(assettype='machine')
|
|
_db.session.add(machinetype)
|
|
_db.session.flush()
|
|
|
|
dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
|
|
_db.session.add(dualpath)
|
|
_db.session.flush()
|
|
|
|
primary_asset = Asset(assetnumber='2007', assettypeid=machinetype.assettypeid,
|
|
mapx=100, mapy=100)
|
|
secondary_asset = Asset(assetnumber='2008', assettypeid=machinetype.assettypeid,
|
|
mapx=110, mapy=110)
|
|
_db.session.add_all([primary_asset, secondary_asset])
|
|
_db.session.flush()
|
|
|
|
primary_machine = Machine(assetid=primary_asset.assetid)
|
|
secondary_machine = Machine(assetid=secondary_asset.assetid)
|
|
_db.session.add_all([primary_machine, secondary_machine])
|
|
_db.session.flush()
|
|
|
|
_db.session.add(AssetRelationship(
|
|
sourceassetid=primary_asset.assetid,
|
|
targetassetid=secondary_asset.assetid,
|
|
relationshiptypeid=dualpath.relationshiptypeid,
|
|
))
|
|
_db.session.commit()
|
|
return primary_machine, secondary_machine
|
|
|
|
|
|
def _set_toggle(value):
|
|
Setting.set('dualpath_single_machine', value, valuetype='boolean',
|
|
category='site')
|
|
invalidate_settings_cache()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setting ON (default): pair collapses to the primary bay
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_list_collapses_secondary_and_annotates_primary(client, db, auth_headers):
|
|
invalidate_settings_cache() # default on (no row)
|
|
_seed_dualpath_pair()
|
|
|
|
resp = client.get('/api/machines', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
payload = resp.get_json()
|
|
items = payload['data']
|
|
numbers = [i['assetnumber'] for i in items]
|
|
|
|
# secondary bay (2008) is hidden; total reflects the collapse
|
|
assert numbers == ['2007']
|
|
assert payload['meta']['pagination']['total'] == 1
|
|
|
|
# the visible primary carries its partner
|
|
partner = items[0]['dualpathpartner']
|
|
assert partner is not None
|
|
assert partner['assetnumber'] == '2008'
|
|
assert partner['assetid'] is not None
|
|
assert partner['machineid'] is not None
|
|
|
|
|
|
def test_dashboard_machine_count_collapses(client, db, auth_headers):
|
|
invalidate_settings_cache()
|
|
_seed_dualpath_pair()
|
|
|
|
resp = client.get('/api/dashboard', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
counts = resp.get_json()['data']
|
|
# two machine rows, one physical machine
|
|
assert counts['totalmachines'] == 1
|
|
assert counts['counts']['machines'] == 1
|
|
|
|
|
|
def test_an_unpositioned_secondary_is_represented_by_the_primary(
|
|
client, db, auth_headers):
|
|
"""The common case: the pair sits in one place, so it draws one marker and
|
|
that marker is labelled with both bays."""
|
|
invalidate_settings_cache()
|
|
_seed_dualpath_pair()
|
|
secondary = Asset.query.filter_by(assetnumber='2008').first()
|
|
secondary.mapx = None
|
|
secondary.mapy = None
|
|
_db.session.commit()
|
|
|
|
resp = client.get('/api/assets/map', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assets = resp.get_json()['data']['assets']
|
|
numbers = [a['assetnumber'] for a in assets]
|
|
|
|
assert '2007' in numbers
|
|
assert '2008' not in numbers
|
|
primary = next(a for a in assets if a['assetnumber'] == '2007')
|
|
assert primary['dualpathpartner'] == '2008', \
|
|
'the one marker has to say it stands for both bays'
|
|
|
|
|
|
def test_a_positioned_secondary_draws_its_own_marker(client, db, auth_headers):
|
|
"""A second bay that is physically somewhere else. Positioning it used to
|
|
be a silent no-op: the form took the pin, stored it, and the map never drew
|
|
it, because the secondary was excluded from the feed unconditionally."""
|
|
invalidate_settings_cache()
|
|
_seed_dualpath_pair() # fixture positions BOTH bays
|
|
|
|
resp = client.get('/api/assets/map', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assets = resp.get_json()['data']['assets']
|
|
numbers = [a['assetnumber'] for a in assets]
|
|
|
|
assert '2007' in numbers
|
|
assert '2008' in numbers, 'a positioned secondary bay draws its own marker'
|
|
|
|
# Two pins, two names. Labelling both '2007 / 2008' would say the same
|
|
# machine is in two places.
|
|
for assetnumber in ('2007', '2008'):
|
|
marker = next(a for a in assets if a['assetnumber'] == assetnumber)
|
|
assert marker['dualpathpartner'] is None, assetnumber
|
|
|
|
|
|
def test_detail_banner_partner_shown_regardless_of_toggle(client, db, auth_headers):
|
|
invalidate_settings_cache()
|
|
primary_machine, secondary_machine = _seed_dualpath_pair()
|
|
|
|
# banner data is present on BOTH bays even with the toggle off
|
|
_set_toggle(False)
|
|
for machine_id, expected in (
|
|
(primary_machine.machineid, '2008'),
|
|
(secondary_machine.machineid, '2007'),
|
|
):
|
|
resp = client.get(f'/api/machines/{machine_id}', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
partner = resp.get_json()['data']['dualpathpartner']
|
|
assert partner is not None
|
|
assert partner['assetnumber'] == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setting OFF: both bays list and count separately
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_list_shows_both_bays_when_disabled(client, db, auth_headers):
|
|
_seed_dualpath_pair()
|
|
_set_toggle(False)
|
|
|
|
resp = client.get('/api/machines', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
payload = resp.get_json()
|
|
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
|
|
|
assert numbers == ['2007', '2008']
|
|
assert payload['meta']['pagination']['total'] == 2
|
|
|
|
|
|
def test_dashboard_counts_both_bays_when_disabled(client, db, auth_headers):
|
|
_seed_dualpath_pair()
|
|
_set_toggle(False)
|
|
|
|
resp = client.get('/api/dashboard', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert resp.get_json()['data']['totalmachines'] == 2
|
|
|
|
|
|
def test_map_shows_both_bays_when_disabled(client, db, auth_headers):
|
|
_seed_dualpath_pair()
|
|
_set_toggle(False)
|
|
|
|
resp = client.get('/api/assets/map', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
|
assert '2007' in numbers
|
|
assert '2008' in numbers
|
|
|
|
|
|
def _give_both_bays_a_status():
|
|
"""The pair fixture leaves statusid null, so the bystatus join matches
|
|
nothing. Give both bays the same status, which is what a real site has and
|
|
what makes the breakdown meaningful."""
|
|
from shopdb.core.models import AssetStatus
|
|
|
|
status = AssetStatus(status='In Use')
|
|
_db.session.add(status)
|
|
_db.session.flush()
|
|
for assetnumber in ('2007', '2008'):
|
|
asset = Asset.query.filter_by(assetnumber=assetnumber).first()
|
|
asset.statusid = status.statusid
|
|
_db.session.commit()
|
|
|
|
|
|
def test_the_status_breakdown_adds_up_to_the_total(client, db, auth_headers):
|
|
"""The dualpath exclusion was applied to total and bytype but not to
|
|
bystatus, so the status bars summed to more than the headline they sit
|
|
under - by exactly the number of secondary bays. A breakdown that does not
|
|
add up to its own total is an afternoon of reconciling."""
|
|
invalidate_settings_cache()
|
|
_seed_dualpath_pair()
|
|
_give_both_bays_a_status()
|
|
|
|
resp = client.get('/api/machines/dashboard/summary', headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
summary = resp.get_json()['data']
|
|
|
|
assert summary['total'] == 1, 'two bays, one physical machine'
|
|
assert sum(row['count'] for row in summary['bystatus']) == summary['total']
|
|
|
|
|
|
def test_the_status_breakdown_counts_both_bays_when_collapse_is_off(
|
|
client, db, auth_headers):
|
|
"""With the setting off, every bay is its own machine everywhere - the fix
|
|
must not start hiding rows a site asked to see."""
|
|
invalidate_settings_cache()
|
|
_seed_dualpath_pair()
|
|
_give_both_bays_a_status()
|
|
_set_toggle('false')
|
|
|
|
summary = client.get('/api/machines/dashboard/summary',
|
|
headers=auth_headers).get_json()['data']
|
|
assert summary['total'] == 2
|
|
assert sum(row['count'] for row in summary['bystatus']) == 2
|
|
|
|
|
|
def test_a_maintenance_date_is_served_as_a_plain_date(client, db, auth_headers):
|
|
"""It was a DATETIME holding midnight, serialised as '...T00:00:00Z', so a
|
|
browser west of UTC rendered every maintenance date one day early. A date
|
|
has no time and no timezone."""
|
|
from plugins.machines.models import Machine
|
|
|
|
# The create route resolves the 'machine' AssetType, which this module's
|
|
# fixture only seeds as part of building a pair.
|
|
if not AssetType.query.filter_by(assettype='machine').first():
|
|
_db.session.add(AssetType(assettype='machine'))
|
|
_db.session.commit()
|
|
|
|
created = client.post('/api/machines', headers=auth_headers, json={
|
|
'assetnumber': 'MAINT-DATE-1',
|
|
'name': 'Maintenance date probe',
|
|
'lastmaintenancedate': '2026-01-15',
|
|
'nextmaintenancedate': '2026-12-15',
|
|
})
|
|
assert created.status_code == 201, created.get_json()
|
|
payload = created.get_json()['data']
|
|
machineid = payload.get('machineid') or payload['machine']['machineid']
|
|
|
|
fetched = client.get('/api/machines/%d' % machineid,
|
|
headers=auth_headers).get_json()['data']
|
|
extension = fetched['machine']
|
|
assert extension['lastmaintenancedate'] == '2026-01-15'
|
|
assert extension['nextmaintenancedate'] == '2026-12-15'
|
|
for value in (extension['lastmaintenancedate'],
|
|
extension['nextmaintenancedate']):
|
|
assert 'T' not in value and 'Z' not in value, \
|
|
'a date must not carry a time or a timezone marker'
|
|
|
|
stored = db.session.get(Machine, machineid)
|
|
assert str(stored.lastmaintenancedate) == '2026-01-15'
|