Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:14:53 -04:00
parent ddf41c3e65
commit ff9ae693a6
112 changed files with 5182 additions and 857 deletions

View File

@@ -7,7 +7,8 @@ from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response,
from ..models import Machine, MachineType
from shopdb.api import require_permission, apply_import_timestamps
from shopdb.api import (require_permission, apply_import_timestamps, intarg,
DateValueError, parse_date_fields)
machines_bp = Blueprint('machines', __name__)
@@ -197,20 +198,20 @@ def list_machines():
)
# Machine type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Machine.machinetypeid == int(type_id))
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Machine.machinetypeid == type_id)
# Vendor filter
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Machine.vendorid == int(vendor_id))
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
query = query.filter(Machine.vendorid == vendor_id)
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Sorting
sort_by = request.args.get('sort', 'assetnumber')
@@ -336,6 +337,14 @@ def create_machine():
- mapx, mapy, notes
"""
data = request.get_json()
# Refused rather than stored raw or nulled: these are db.Date columns
# (migration 7d40), so a string would be rejected by the engine and a
# mistyped one used to become NULL further down the stack.
try:
maintenancedates = parse_date_fields(
data, ('lastmaintenancedate', 'nextmaintenancedate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@@ -388,8 +397,8 @@ def create_machine():
modelnumberid=data.get('modelnumberid'),
requiresmanualconfig=data.get('requiresmanualconfig', False),
islocationonly=data.get('islocationonly', False),
lastmaintenancedate=data.get('lastmaintenancedate'),
nextmaintenancedate=data.get('nextmaintenancedate'),
lastmaintenancedate=maintenancedates.get('lastmaintenancedate'),
nextmaintenancedate=maintenancedates.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays'),
controllervendorid=data.get('controllervendorid'),
controllermodelid=data.get('controllermodelid')
@@ -427,6 +436,14 @@ def update_machine(machine_id: int):
)
data = request.get_json()
# Refused rather than stored raw or nulled: these are db.Date columns
# (migration 7d40), so a string would be rejected by the engine and a
# mistyped one used to become NULL further down the stack.
try:
maintenancedates = parse_date_fields(
data, ('lastmaintenancedate', 'nextmaintenancedate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@@ -471,7 +488,7 @@ def update_machine(machine_id: int):
# Update machine fields
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'maintenanceintervaldays',
'controllervendorid', 'controllermodelid']
for key in machine_fields:
if key in data:
@@ -481,6 +498,14 @@ def update_machine(machine_id: int):
changes[key] = {'old': old_val, 'new': new_val}
setattr(mach, key, data[key])
# The maintenance dates are real dates now (migration 7d40), so they are
# parsed rather than assigned as whatever string arrived - and a mistyped
# one is refused instead of stored or nulled, like every other date.
for key, parsed in maintenancedates.items():
if getattr(mach, key) != parsed:
changes[key] = {'old': getattr(mach, key), 'new': parsed}
setattr(mach, key, parsed)
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
@@ -553,16 +578,22 @@ def dashboard_summary():
by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
by_type = by_type_query.group_by(MachineType.machinetype).all()
# Count by status
# Count by status. The dualpath exclusion applies here too: it was applied
# to total and bytype and NOT to this one, so the status bars summed to
# more than the total they sit under - by exactly the number of dualpath
# secondaries. A breakdown that does not add up to its own headline is the
# kind of thing somebody spends an afternoon reconciling.
from shopdb.api import AssetStatus
by_status = db.session.query(
by_status_query = db.session.query(
AssetStatus.status,
db.func.count(Machine.machineid)
).join(Asset, Asset.assetid == Machine.assetid
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
).filter(Asset.isactive == True
).group_by(AssetStatus.status
).all()
).filter(Asset.isactive == True)
if secondaryassetids:
by_status_query = by_status_query.filter(
Asset.assetid.notin_(secondaryassetids))
by_status = by_status_query.group_by(AssetStatus.status).all()
return success_response({
'total': total,

View File

@@ -9,14 +9,24 @@
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Label
</router-link>
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine && !isRetired">
Edit
</router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
<button @click="confirmRetire" class="btn btn-danger" v-if="machine && !isRetired">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live one,
with nothing on the page saying it was retired. Restoring it was
possible through the API and nowhere in the product. -->
<button @click="restore" class="btn btn-primary" v-if="machine && isRetired">Restore</button>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="machine && isRetired" class="retired-banner card">
<strong>This machine is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine">
@@ -295,7 +305,7 @@ const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.ass
// relationships render via the shared AssetRelationships card
onMounted(async () => {
async function loadMachine() {
try {
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
@@ -305,7 +315,9 @@ onMounted(async () => {
} finally {
loading.value = false
}
})
}
onMounted(loadMachine)
function getStatusClass(status) {
if (!status) return 'badge-info'
@@ -324,6 +336,20 @@ function formatDate(dateStr) {
// Retire, not delete: the server deactivates the asset, so the machine
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
const isRetired = computed(() => machine.value?.isactive === false)
async function restore() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
try {
await machinesApi.update(machine.value?.machine?.machineid, { isactive: true })
toast.success(`Restored ${label}`)
await loadMachine()
} catch (error) {
console.error('Error restoring machine:', error)
toast.error(apiError(error, 'Failed to restore machine'))
}
}
async function confirmRetire() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -340,6 +366,10 @@ async function confirmRetire() {
</script>
<style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}

View File

@@ -416,6 +416,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
import { apiError } from '@/utils/apiError'
import { planControllingPcChange } from './controllingPc'
@@ -426,8 +427,11 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for machines (see /api/assets/types).
const MACHINE_ASSETTYPEID = 1
// Resolved by NAME at load, never hardcoded: the id comes from seed
// order, so the constant that used to live here pointed at a different
// asset type on a differently-seeded site - the form then rendered THAT
// type's custom fields and dropped whatever was typed into them.
const MACHINE_ASSETTYPEID = computed(() => assetTypeId('machine'))
const customFieldsRef = ref(null)
const printerPickerRef = ref(null)
const currentAssetId = ref(null)
@@ -571,15 +575,16 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
})
onMounted(async () => {
await loadAssetTypeIds()
try {
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
machinesApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }),
vendorsApi.listAll(),
locationsApi.listAll(), // backend caps perpage at 100; page through all
modelsApi.listAll({ category: 'Equipment' }), // backend caps perpage at 100; page through all
businessunitsApi.list({ perpage: 500 }),
businessunitsApi.listAll(),
computersApi.listAll(), // backend caps perpage at 100; page through all
assetsApi.types.list() // Used for relationship types, will fix below
])
@@ -763,7 +768,13 @@ async function saveMachine() {
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
notes: form.value.notes || null,
mapx: form.value.mapx,
mapy: form.value.mapy
mapy: form.value.mapy,
// ADR-017: a position without its level cannot be drawn on the right
// floor plan. The picker sets all three and this payload sent two, so a
// picked pin saved a levelid of null and then rendered nowhere - the
// form even showed "Position: x, y" back, because it kept the value it
// had never sent.
levelid: form.value.levelid
}
let savedMachine

View File

@@ -95,7 +95,7 @@ onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await machinesApi.types.list({ perpage: 200, active: false })
const response = await machinesApi.types.list({ perpage: 100, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)

View File

@@ -15,6 +15,14 @@
placeholder="Search machines..."
@input="debouncedSearch"
/>
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise all along (?active=false
lists them, PUT isactive restores) but nothing in the UI could reach
it, so a retired machine was simply gone. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadMachines" />
Show retired
</label>
</div>
<div class="card">
@@ -41,6 +49,7 @@
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
<td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
<span v-if="item.isactive === false" class="badge badge-secondary">retired</span>
</td>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
@@ -122,6 +131,7 @@ function applySuggestion(word) {
// search page documents. Load directly.
loadMachines()
}
const showRetired = ref(false)
const totalPages = ref(1)
const perPage = ref(20)
@@ -139,6 +149,8 @@ async function loadMachines() {
perpage: perPage.value
}
if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
const response = await machinesApi.list(params)
machines.value = response.data.data || []

View File

@@ -73,8 +73,12 @@ class Machine(BaseModel):
)
# Maintenance tracking
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
# Dates, not datetimes: there is no time in a maintenance date, and a
# DATETIME went out of to_dict as "...T00:00:00Z", which a browser west of
# UTC rendered as the previous day. measuringtools and warranties already
# model theirs as db.Date. See migration 7d40.
lastmaintenancedate = db.Column(db.Date, nullable=True)
nextmaintenancedate = db.Column(db.Date, nullable=True)
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Controller info (for CNC machines)