Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user