Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -20,6 +20,7 @@ from shopdb.api import (
|
||||
success_response, error_response, paginated_response, ErrorCodes,
|
||||
get_pagination_params, paginate_query,
|
||||
require_permission, apply_import_timestamps,
|
||||
DateValueError, parse_date_fields,
|
||||
)
|
||||
|
||||
from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS
|
||||
@@ -27,16 +28,6 @@ from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COL
|
||||
measuringtools_bp = Blueprint('measuringtools', __name__)
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _merged(tool, today=None):
|
||||
"""Asset core dict with the measuringtools extension nested under 'measuringtool'."""
|
||||
result = tool.asset.to_dict() if tool.asset else {}
|
||||
@@ -191,19 +182,32 @@ def list_tools():
|
||||
query = query.filter(Asset.statusid == status_id)
|
||||
|
||||
query = query.order_by(Asset.assetnumber)
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
|
||||
today = date.today()
|
||||
data = [_merged(tool, today) for tool in items]
|
||||
|
||||
# Calibration status is derived, so it cannot be a SQL filter - apply it
|
||||
# to the built rows. Filtering post-pagination is acceptable here because
|
||||
# the dataset is a gage lab's worth of tools, not a fleet.
|
||||
# Calibration status is DERIVED (see derive_status), so it cannot be a SQL
|
||||
# filter. It used to be applied to the rows of the current page, which
|
||||
# meant the page showed the matches that happened to fall on it while
|
||||
# meta.pagination.total counted the unfiltered set: asking for 'overdue'
|
||||
# with one overdue tool in sixteen returned an empty page 1, a total of 16
|
||||
# and eight pages, with the actual row on page 3. A filter that hides
|
||||
# matching rows and miscounts the rest is worse than no filter.
|
||||
#
|
||||
# So when this filter is in play the whole matching set is built and
|
||||
# filtered before paging. That is the same tradeoff the previous comment
|
||||
# named - a gage lab's worth of tools, not a fleet - spent where it buys
|
||||
# correct answers rather than a cheaper query.
|
||||
calibrationstatus = request.args.get('calibrationstatus')
|
||||
if calibrationstatus:
|
||||
data = [d for d in data
|
||||
if d['measuringtool']['calibrationstatus'] == calibrationstatus]
|
||||
matched = [_merged(tool, today) for tool in query.all()]
|
||||
matched = [row for row in matched
|
||||
if row['measuringtool']['calibrationstatus'] == calibrationstatus]
|
||||
total = len(matched)
|
||||
start = (page - 1) * per_page
|
||||
data = matched[start:start + per_page]
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [_merged(tool, today) for tool in items]
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@@ -240,6 +244,12 @@ def _asset_type_id():
|
||||
def create_tool():
|
||||
"""Create a measuring tool: one Asset core row + one extension row."""
|
||||
data = request.get_json() or {}
|
||||
# Refused, not nulled - see the note at the update site.
|
||||
try:
|
||||
dates = parse_date_fields(
|
||||
data, ('lastcalibrationdate', 'nextcalibrationdate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
if not data.get('assetnumber'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
|
||||
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
||||
@@ -275,8 +285,8 @@ def create_tool():
|
||||
assetid=asset.assetid,
|
||||
measuringtooltypeid=data.get('measuringtooltypeid'),
|
||||
calibrationintervaldays=data.get('calibrationintervaldays'),
|
||||
lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')),
|
||||
nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')),
|
||||
lastcalibrationdate=dates.get('lastcalibrationdate'),
|
||||
nextcalibrationdate=dates.get('nextcalibrationdate'),
|
||||
calibrationprovider=(data.get('calibrationprovider') or '').strip() or None,
|
||||
notes=(data.get('notes') or '').strip() or None,
|
||||
)
|
||||
@@ -309,6 +319,12 @@ def update_tool(tool_id: int):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring tool {tool_id} not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
# Refused, not nulled - see the note at the update site.
|
||||
try:
|
||||
dates = parse_date_fields(
|
||||
data, ('lastcalibrationdate', 'nextcalibrationdate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
asset = tool.asset
|
||||
|
||||
# An UPDATE may not blank the asset number. Create validates it and the
|
||||
@@ -339,9 +355,11 @@ def update_tool(tool_id: int):
|
||||
if getattr(tool, key) != data[key]:
|
||||
changes[key] = {'old': getattr(tool, key), 'new': data[key]}
|
||||
setattr(tool, key, data[key])
|
||||
for key in ('lastcalibrationdate', 'nextcalibrationdate'):
|
||||
if key in data:
|
||||
setattr(tool, key, _parse_date(data[key]))
|
||||
# Refused, not nulled: a mistyped calibration date used to store NULL and
|
||||
# answer 200, which flipped the tool to 'unknown' and dropped it out of the
|
||||
# calibration report and the map overlay.
|
||||
for key, parsed in dates.items():
|
||||
setattr(tool, key, parsed)
|
||||
|
||||
if changes:
|
||||
AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid,
|
||||
|
||||
@@ -9,11 +9,21 @@
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button>
|
||||
<button v-if="tool && !isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<!-- A retired record offered Edit and Retire exactly like a live
|
||||
one, and nothing said it was retired. Restoring it was possible
|
||||
through the API and nowhere in the product. -->
|
||||
<button v-if="tool && isRetired" @click="restore" class="btn btn-primary">Restore</button>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tool && tool.isactive === false" class="retired-banner card">
|
||||
<strong>This measuring tool 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="tool">
|
||||
@@ -181,7 +191,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '@/api'
|
||||
@@ -227,6 +237,21 @@ onMounted(async () => {
|
||||
// Retire, not delete: the server deactivates the asset, so the measuring tool
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
const isRetired = computed(() => tool.value?.isactive === false)
|
||||
|
||||
async function restore() {
|
||||
const label = tool.value?.assetnumber || tool.value?.name || 'this tool'
|
||||
try {
|
||||
await measuringtoolsApi.update(route.params.id, { isactive: true })
|
||||
toast.success(`Restored ${label}`)
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
tool.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error restoring:', error)
|
||||
toast.error(apiError(error, 'Failed to restore'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetire() {
|
||||
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
@@ -243,6 +268,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;
|
||||
}
|
||||
|
||||
@@ -179,10 +179,10 @@ const businessunits = ref([])
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
||||
measuringtoolsApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
businessunitsApi.listAll(),
|
||||
assetsApi.types.list(),
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
|
||||
@@ -107,7 +107,7 @@ onMounted(() => loadData())
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await measuringtoolsApi.types.list({ perpage: 200, active: false })
|
||||
const response = await measuringtoolsApi.types.list({ perpage: 100, active: false })
|
||||
types.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool types:', err)
|
||||
|
||||
@@ -28,7 +28,14 @@
|
||||
<option value="current">Current</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Retire is a soft delete and the confirm dialog promises the record
|
||||
"can be restored". The API kept that promise (?active=false lists
|
||||
them, PUT isactive restores); nothing in the UI could reach it. -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showRetired" @change="loadTools" />
|
||||
Show retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -115,6 +122,7 @@ function applySuggestion(word) {
|
||||
}
|
||||
const typeid = ref('')
|
||||
const calibrationstatus = ref('')
|
||||
const showRetired = ref(false)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -126,7 +134,7 @@ function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
|
||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 100 })
|
||||
types.value = typesResponse.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading types:', err)
|
||||
@@ -139,6 +147,8 @@ async function loadTools() {
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
// active=false means "include retired", matching the API.
|
||||
if (showRetired.value) params.active = 'false'
|
||||
if (typeid.value) params.typeid = typeid.value
|
||||
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
|
||||
|
||||
|
||||
Reference in New Issue
Block a user