Fix PC installed-applications rendering; minor UI cleanups
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

The PC detail Installed Applications section 500d and vanished on any
real PC: ComputerInstalledApp had no to_dict, so the endpoint errored
and the v-if hid the section. Added the serializer (curated version
wins over the raw collected string, app name + description included)
and aligned PCDetail to the flat payload; regression test added.

Also: employee detail skips its USB panels when the usb plugin is
disabled (was firing 404s), and the shopfloor kiosk header is now
light-on-dark for readability.

810 tests pass; PC 259 installed apps verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 13:37:58 -04:00
parent 68b6949f1c
commit d187a2c535
6 changed files with 104 additions and 9 deletions

View File

@@ -10,6 +10,16 @@ ADR-007 and ADR-002.
## [Unreleased] ## [Unreleased]
### Fixed
- PC detail Installed Applications no longer 500s and silently disappears on
real PCs (ComputerInstalledApp had no to_dict); the section renders app
name, version, and description again.
- Employee detail skips its USB panels when the usb plugin is disabled (no
more 404 console noise).
- Shopfloor kiosk header text is readable (light on the dark navy header).
### Added ### Added
- Single-label sheet-position printing. The single asset-label page - Single-label sheet-position printing. The single asset-label page

View File

@@ -444,7 +444,7 @@ function handlePhotoError(e) {
.location-title { .location-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #888; color: #b8c4d8;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 2px; letter-spacing: 2px;
} }
@@ -452,6 +452,7 @@ function handlePhotoError(e) {
.header-center h1 { .header-center h1 {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: #ffffff;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 2px; letter-spacing: 2px;
margin: 0; margin: 0;

View File

@@ -64,7 +64,7 @@
</div> </div>
<!-- Currently Checked Out USB Devices --> <!-- Currently Checked Out USB Devices -->
<div class="section-card"> <div v-if="usbEnabled" class="section-card">
<h2 class="section-title">Checked Out USB Devices</h2> <h2 class="section-title">Checked Out USB Devices</h2>
<div v-if="usbLoading" class="loading">Loading...</div> <div v-if="usbLoading" class="loading">Loading...</div>
<div v-else-if="usbDevices.length === 0" class="empty"> <div v-else-if="usbDevices.length === 0" class="empty">
@@ -99,7 +99,7 @@
</div> </div>
<!-- USB Checkout History --> <!-- USB Checkout History -->
<div class="section-card"> <div v-if="usbEnabled" class="section-card">
<h2 class="section-title">USB Checkout History</h2> <h2 class="section-title">USB Checkout History</h2>
<div v-if="historyLoading" class="loading">Loading...</div> <div v-if="historyLoading" class="loading">Loading...</div>
<div v-else-if="checkoutHistory.length === 0" class="empty"> <div v-else-if="checkoutHistory.length === 0" class="empty">
@@ -136,6 +136,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { employeesApi, usbApi, notificationsApi } from '@/api' import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '../../composables/toast' import { useToast } from '../../composables/toast'
const toast = useToast() const toast = useToast()
@@ -145,6 +146,7 @@ const employee = ref(null)
const recognitions = ref([]) const recognitions = ref([])
const usbDevices = ref([]) const usbDevices = ref([])
const checkoutHistory = ref([]) const checkoutHistory = ref([])
const usbEnabled = ref(true)
const loading = ref(true) const loading = ref(true)
const recognitionsLoading = ref(true) const recognitionsLoading = ref(true)
const usbLoading = ref(true) const usbLoading = ref(true)
@@ -175,7 +177,13 @@ const initials = computed(() => {
onMounted(async () => { onMounted(async () => {
await loadEmployee() await loadEmployee()
await Promise.all([loadRecognitions(), loadUSBDevices(), loadCheckoutHistory()]) // Skip the USB panels entirely when the usb plugin is disabled - its
// /api/usb routes 404 otherwise and spam the console.
await loadEnabledPlugins()
usbEnabled.value = isPluginEnabled('usb')
const tasks = [loadRecognitions()]
if (usbEnabled.value) tasks.push(loadUSBDevices(), loadCheckoutHistory())
await Promise.all(tasks)
}) })
async function loadEmployee() { async function loadEmployee() {

View File

@@ -191,15 +191,15 @@
<router-link <router-link
v-for="app in installedApps" v-for="app in installedApps"
:key="app.id" :key="app.id"
:to="`/applications/${app.application?.appid}`" :to="`/applications/${app.appid}`"
class="app-item" class="app-item"
> >
<div class="app-info"> <div class="app-info">
<span class="app-name">{{ app.application?.appname }}</span> <span class="app-name">{{ app.appname }}</span>
<span class="app-version" v-if="app.version">v{{ app.version }}</span> <span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
</div> </div>
<div class="app-desc" v-if="app.application?.appdescription"> <div class="app-desc" v-if="app.appdescription">
{{ app.application.appdescription }} {{ app.appdescription }}
</div> </div>
</router-link> </router-link>
</div> </div>

View File

@@ -183,6 +183,25 @@ class ComputerInstalledApp(db.Model):
db.Index('idx_compapp_app', 'appid'), db.Index('idx_compapp_app', 'appid'),
) )
def to_dict(self):
# Curated AppVersion wins; else the raw collected version string.
version = None
if self.appversion is not None:
version = self.appversion.version
elif self.installedversion:
version = self.installedversion
return {
'id': self.id,
'computerid': self.computerid,
'appid': self.appid,
'appname': self.application.appname if self.application else None,
'appdescription': self.application.appdescription if self.application else None,
'appversionid': self.appversionid,
'installedversion': version,
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
'isactive': self.isactive,
}
class AccessProtocol(db.Model): class AccessProtocol(db.Model):
""" """

View File

@@ -0,0 +1,57 @@
"""Installed-applications detail endpoint serializes correctly.
Regression guard: GET /api/applications/machines/<computerid> serializes each
ComputerInstalledApp via its to_dict(). A missing to_dict raised a 500 that the
PCDetail page swallowed (v-if on a non-empty list), so real PCs silently showed
no installed software. This exercises the with-data path that the empty-list
case short-circuits past.
"""
from shopdb.extensions import db as _db
from shopdb.core.models import Asset, AssetType, Application
from plugins.computers.models import Computer, ComputerInstalledApp
def _seed_pc_with_app(client_db):
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
_db.session.add(atype)
_db.session.flush()
asset = Asset(assetnumber='PC-APPTEST', assettypeid=atype.assettypeid)
_db.session.add(asset)
_db.session.flush()
comp = Computer(assetid=asset.assetid, hostname='PC-APPTEST')
_db.session.add(comp)
_db.session.flush()
app = Application(appname='Test App')
_db.session.add(app)
_db.session.flush()
link = ComputerInstalledApp(computerid=comp.computerid, appid=app.appid,
installedversion='1.2.3')
_db.session.add(link)
_db.session.commit()
return comp
def test_installed_apps_endpoint_returns_data(client, db, auth_headers):
comp = _seed_pc_with_app(db)
resp = client.get(f'/api/applications/machines/{comp.computerid}',
headers=auth_headers)
assert resp.status_code == 200
rows = resp.get_json()['data']
assert len(rows) == 1
row = rows[0]
assert row['appname'] == 'Test App'
assert row['installedversion'] == '1.2.3'
assert row['computerid'] == comp.computerid
def test_installed_app_to_dict_prefers_curated_version(db):
comp = _seed_pc_with_app(db)
link = ComputerInstalledApp.query.filter_by(computerid=comp.computerid).first()
data = link.to_dict()
assert data['appname'] == 'Test App'
assert data['installedversion'] == '1.2.3'
assert data['isactive'] is True