One printer picker for machines and PCs, and one default per asset
The assignment belongs to the MACHINE, and until now there was no way to set it except the generic relationships card or the API - the form for the thing the feature is about did not exist. MachineForm now carries the picker, and PCForm uses the SAME component rather than its own copy: the PC's set overrides the machine's, and two implementations of that would drift, with the two ends of an override disagreeing being exactly the bug nobody would spot. The shared picker also fixes what PCForm did on save. It wrote row at a time through the generic relationship endpoints, which is a non-atomic reconcile: an HTTP failure part way left a PC half-assigned with nothing recording what was meant. It now calls the reconcile endpoint, which validates the default before writing anything. A relationship type can now say it allows one active row per asset (relationshiptypes.issingular, migration 7d34), and defaultprinter says it. Cardinality belongs to the type rather than the printers plugin: core's create path is where every hand-made link passes, and the next type meaning "exactly one" gets the rule for free. Setting a second default REPLACES the first instead of refusing, because "make this the default" means that - and a card answering 409 would leave the user hunting for the old row. Without it the schema was happy to hold two defaults: the unique constraint is (source, target, type), so two different targets are two valid rows, and the resolver takes the OLDEST - the new default silently lost. Proven by disabling the new rule and watching the tests fail. FOUND WHILE TESTING IN A BROWSER, and it was not mine: MachineForm read .data.data off computersApi.listAll(), which resolves to the ARRAY - fetchAllPages has already unwrapped every page. The whole parallel load threw into the catch, so every dropdown on the machine edit form came up empty and the machine's own values never loaded. A build cannot see this; only opening the page can. GET /api/printers/assignments/for-asset/<id> returns an asset's OWN assignment, without inheritance, because the editor must show what this asset's rows say - otherwise a machine's printers appear ticked on the PC that inherits them and unticking one silently creates an override.
This commit is contained in:
@@ -3047,6 +3047,14 @@
|
||||
"params": "none",
|
||||
"purpose": "Dashboard card: printers needing a cartridge, one row per printer. Reuses the low-supplies query and its five-minute cache, so the card costs the same as the report",
|
||||
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/dashboard/supplies"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/printers/assignments/for-asset/<asset_id>",
|
||||
"auth": "optional jwt",
|
||||
"params": "asset_id in path",
|
||||
"purpose": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The editor has to show what this asset's own rows say, or a machine's printers would appear ticked on the PC that inherits them and unticking one would silently create an override. /printers/for-host is the resolved view the client uses; this is the editable one",
|
||||
"example": "curl http://localhost:5001/api/printers/assignments/for-asset/14"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "ShopDB Flask API",
|
||||
"version": "0.11.2",
|
||||
"version": "0.11.3",
|
||||
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
|
||||
},
|
||||
"servers": [
|
||||
@@ -16862,6 +16862,50 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-printers"
|
||||
],
|
||||
"summary": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The...",
|
||||
"description": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The editor has to show what this asset's own rows say, or a machine's printers would appear ticked on the PC that inherits them and unticking one would silently create an override. /printers/for-host is the resolved view the client uses; this is the editable one\n\n**Auth:** optional jwt\n\n**Params:** asset_id in path\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/assignments/for-asset/14\n```",
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/printers/{printer_id}": {
|
||||
|
||||
@@ -306,6 +306,25 @@ export const printersApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printers', { params })
|
||||
},
|
||||
|
||||
// Printer assignment for one asset - a MACHINE normally, since the assignment
|
||||
// belongs to the bay and reaches whichever PC controls it. Reads and writes
|
||||
// that asset's OWN rows: the resolved view (own, else inherited) is
|
||||
// /printers/for-host, which the client uses, not the editor.
|
||||
assignment: {
|
||||
get(assetid) {
|
||||
return api.get(`/printers/assignments/for-asset/${assetid}`)
|
||||
},
|
||||
// The WHOLE set in one call. A row-at-a-time edit is a non-atomic
|
||||
// reconcile: an HTTP failure part way leaves an asset half-assigned, with
|
||||
// nothing recording what was meant.
|
||||
set(assetid, printerassetids, defaultprinterassetid) {
|
||||
return api.put(`/printers/assignments/for-asset/${assetid}`, {
|
||||
printerassetids,
|
||||
defaultprinterassetid: defaultprinterassetid ?? null
|
||||
})
|
||||
}
|
||||
},
|
||||
// Every printer, paged past the backend's 100-row cap. Batch label printing
|
||||
// and "pick any record" dropdowns must use this: list() with a large
|
||||
// perpage is clamped to 100 and still returns a success response, so
|
||||
|
||||
168
frontend/src/components/PrinterAssignmentPicker.vue
Normal file
168
frontend/src/components/PrinterAssignmentPicker.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<template v-if="enabled">
|
||||
<h4 class="printer-heading">Printers</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label :for="`printersearch-${uid}`">Assigned printers</label>
|
||||
<input
|
||||
:id="`printersearch-${uid}`"
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Filter printers..."
|
||||
/>
|
||||
<div class="printer-list">
|
||||
<label v-for="option in filtered" :key="option.assetid" class="printer-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="assigned.includes(option.assetid)"
|
||||
@change="toggle(option.assetid, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ label(option) }}</span>
|
||||
<span v-if="option.printer?.modelname" class="printer-meta">
|
||||
{{ option.printer.modelname }}
|
||||
</span>
|
||||
</label>
|
||||
<span v-if="!filtered.length" class="muted">No printers match.</span>
|
||||
</div>
|
||||
<small class="form-hint">{{ hint }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label :for="`defaultprinter-${uid}`">Default printer</label>
|
||||
<select :id="`defaultprinter-${uid}`" v-model="defaultAssetId" class="form-control">
|
||||
<option :value="null">No default</option>
|
||||
<!-- Only what is assigned: a default the bay was never told to install
|
||||
fails to apply, and nothing in ShopDB shows why. -->
|
||||
<option v-for="option in assignedOptions" :key="option.assetid" :value="option.assetid">
|
||||
{{ label(option) }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">
|
||||
Optional. Applied per user at logon, because a default printer is a
|
||||
per-user setting that SYSTEM cannot set for somebody else.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// One picker, used by the machine form and the PC form.
|
||||
//
|
||||
// The assignment belongs to the MACHINE - that is what makes a reimaged PC come
|
||||
// back with the bay's printers - and the PC form writes the same shape as an
|
||||
// override. Two copies of this UI would drift, and the two ends of an override
|
||||
// disagreeing is exactly the bug nobody would spot.
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
// The asset being edited. Null while creating: save(assetid) is called with
|
||||
// the new id once it exists.
|
||||
assetid: { type: Number, default: null },
|
||||
// Wording only. What it means to assign here differs: a machine's set is the
|
||||
// bay's, a PC's set overrides the machine it controls.
|
||||
scope: { type: String, default: 'machine' }
|
||||
})
|
||||
|
||||
const uid = Math.random().toString(36).slice(2, 8)
|
||||
const enabled = ref(false)
|
||||
const printers = ref([])
|
||||
const assigned = ref([])
|
||||
const defaultAssetId = ref(null)
|
||||
const search = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return printers.value
|
||||
return printers.value.filter(option => label(option).toLowerCase().includes(term))
|
||||
})
|
||||
|
||||
const assignedOptions = computed(() =>
|
||||
printers.value.filter(option => assigned.value.includes(option.assetid)))
|
||||
|
||||
const hint = computed(() => {
|
||||
const count = assigned.value.length
|
||||
if (props.scope === 'pc') {
|
||||
return `${count} assigned. Printers ticked here belong to this PC and REPLACE `
|
||||
+ 'whatever the machine it controls is assigned - the whole set, not added to it.'
|
||||
}
|
||||
return `${count} assigned. These belong to the machine, so whichever PC controls `
|
||||
+ 'it installs them - including a replacement PC after a reimage.'
|
||||
})
|
||||
|
||||
function label(option) {
|
||||
// A real fleet has printers whose name is the literal string 'NONE' - an
|
||||
// import artefact - and showing that as the label makes two different
|
||||
// printers indistinguishable in the list.
|
||||
const name = option.name && option.name.toUpperCase() !== 'NONE' ? option.name : ''
|
||||
return name || option.assetnumber || `Printer ${option.assetid}`
|
||||
}
|
||||
|
||||
function toggle(assetid, checked) {
|
||||
if (checked) {
|
||||
if (!assigned.value.includes(assetid)) assigned.value.push(assetid)
|
||||
} else {
|
||||
assigned.value = assigned.value.filter(id => id !== assetid)
|
||||
// Unassigning the default clears it rather than leaving a row pointing at a
|
||||
// printer the bay is no longer told to install.
|
||||
if (defaultAssetId.value === assetid) defaultAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
// listAll: perpage is clamped server-side, and a picker that stops at 100
|
||||
// silently hides printers sorting late in the alphabet.
|
||||
printers.value = await printersApi.listAll()
|
||||
enabled.value = true
|
||||
} catch (error) {
|
||||
// A site without the printers plugin has no section at all.
|
||||
enabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssignment(assetid) {
|
||||
if (!enabled.value || !assetid) return
|
||||
try {
|
||||
const response = await printersApi.assignment.get(assetid)
|
||||
const data = response.data.data || {}
|
||||
assigned.value = data.printerassetids || []
|
||||
defaultAssetId.value = data.defaultprinterassetid ?? null
|
||||
} catch (error) {
|
||||
assigned.value = []
|
||||
defaultAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Called by the parent AFTER the asset exists, so a new record can be assigned
|
||||
// in the same save.
|
||||
async function save(assetid) {
|
||||
if (!enabled.value || !assetid) return
|
||||
await printersApi.assignment.set(assetid, assigned.value, defaultAssetId.value)
|
||||
}
|
||||
|
||||
defineExpose({ save })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOptions()
|
||||
await loadAssignment(props.assetid)
|
||||
})
|
||||
|
||||
watch(() => props.assetid, assetid => loadAssignment(assetid))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.printer-heading { margin-top: 1.5rem; margin-bottom: 1rem; }
|
||||
.printer-list {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.printer-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; }
|
||||
.printer-meta { color: var(--text-light); font-size: 0.85em; }
|
||||
.muted { color: var(--text-light); }
|
||||
</style>
|
||||
46
migrations/versions/7d34_singular_relationship_types.py
Normal file
46
migrations/versions/7d34_singular_relationship_types.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Let a relationship type say "at most one of these per asset".
|
||||
|
||||
A PC has one default printer. The unique constraint on assetrelationships is
|
||||
(source, target, type), which happily accepts two DIFFERENT defaults on one
|
||||
asset - and the resolver then takes the oldest, so setting a new default through
|
||||
the generic relationships card left the old one winning, silently.
|
||||
|
||||
Cardinality belongs to the TYPE, not to the printers plugin: the next type that
|
||||
means "exactly one" (a primary user, a primary location) gets the rule for free,
|
||||
and core's create path is where every write already passes.
|
||||
|
||||
Revision ID: 7d34_singular_relationship_types
|
||||
Revises: 7d33_buildings_and_levels
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d34_singular_relationship_types'
|
||||
down_revision = '7d33_buildings_and_levels'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {column['name'] for column in inspector.get_columns('relationshiptypes')}
|
||||
if 'issingular' not in columns:
|
||||
op.add_column('relationshiptypes',
|
||||
sa.Column('issingular', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()))
|
||||
|
||||
# defaultprinter is the type this exists for, and it is already in use, so
|
||||
# set it here rather than waiting for a re-seed: a site that upgrades and
|
||||
# does not re-seed would otherwise keep the old silent behaviour.
|
||||
op.execute("UPDATE relationshiptypes SET issingular = 1 "
|
||||
"WHERE LOWER(relationshiptype) = 'defaultprinter'")
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {column['name'] for column in inspector.get_columns('relationshiptypes')}
|
||||
if 'issingular' in columns:
|
||||
op.drop_column('relationshiptypes', 'issingular')
|
||||
@@ -245,65 +245,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer assignment. Written as plain asset relationships, so this
|
||||
section is absent at a site without the printers plugin. -->
|
||||
<template v-if="printersEnabled">
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printers</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="printersearch">Assigned Printers</label>
|
||||
<input
|
||||
id="printersearch"
|
||||
v-model="printerSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Filter printers..."
|
||||
/>
|
||||
<div class="printer-list">
|
||||
<label
|
||||
v-for="printerAsset in filteredPrinters"
|
||||
:key="printerAsset.assetid"
|
||||
class="printer-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPrinterAssigned(printerAsset.assetid)"
|
||||
@change="togglePrinter(printerAsset.assetid, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ printerLabel(printerAsset) }}</span>
|
||||
<span v-if="printerAsset.printer?.modelname" class="printer-meta">
|
||||
{{ printerAsset.printer.modelname }}
|
||||
</span>
|
||||
</label>
|
||||
<span v-if="!filteredPrinters.length" class="muted">No printers match.</span>
|
||||
</div>
|
||||
<small class="form-hint">
|
||||
{{ assignedPrinters.length }} assigned. Printers ticked here belong to this PC
|
||||
and take the place of any assigned to the machine it controls.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="defaultprinterassetid">Default Printer</label>
|
||||
<select
|
||||
id="defaultprinterassetid"
|
||||
v-model="defaultPrinterAssetId"
|
||||
class="form-control"
|
||||
>
|
||||
<option :value="null">No default</option>
|
||||
<option
|
||||
v-for="printerAsset in assignedPrinters"
|
||||
:key="printerAsset.assetid"
|
||||
:value="printerAsset.assetid"
|
||||
>
|
||||
{{ printerLabel(printerAsset) }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">
|
||||
Optional, and only ever one of the printers assigned above.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Printers assigned to this PC ITSELF, which replace whatever the
|
||||
machine it controls is assigned. Same component the machine form
|
||||
uses: two copies of this UI would drift, and the two ends of an
|
||||
override disagreeing is the bug nobody would spot. -->
|
||||
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="pc" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
@@ -384,6 +330,7 @@ import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
@@ -402,6 +349,7 @@ const isEdit = computed(() => !!route.params.id)
|
||||
// values are keyed by the underlying asset id, captured on load / create.
|
||||
const COMPUTER_ASSETTYPEID = 2
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||
@@ -476,190 +424,6 @@ const models = ref([])
|
||||
const locations = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
// Printer assignment. The rows are ordinary asset relationships - usesprinter
|
||||
// for "installed here", defaultprinter for which of them wins - so the picker
|
||||
// reads and writes them through the generic relationship endpoints.
|
||||
const printersEnabled = ref(false)
|
||||
const printers = ref([])
|
||||
const printerSearch = ref('')
|
||||
const assignedPrinterAssetIds = ref([])
|
||||
const defaultPrinterAssetId = ref(null)
|
||||
const printerRelationshipTypes = ref({ usesprinter: null, defaultprinter: null })
|
||||
// The rows as loaded, so saving writes only what actually changed.
|
||||
const existingPrinterRelationships = ref([])
|
||||
|
||||
function printerLabel(printerAsset) {
|
||||
const name = printerAsset.name && printerAsset.name.toUpperCase() !== 'NONE'
|
||||
? printerAsset.name
|
||||
: null
|
||||
return name || printerAsset.printer?.hostname || printerAsset.assetnumber
|
||||
|| `Asset ${printerAsset.assetid}`
|
||||
}
|
||||
|
||||
const sortedPrinters = computed(() =>
|
||||
[...printers.value].sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
const term = printerSearch.value.trim().toLowerCase()
|
||||
if (!term) return sortedPrinters.value
|
||||
return sortedPrinters.value.filter(printerAsset => {
|
||||
const haystack = [
|
||||
printerLabel(printerAsset),
|
||||
printerAsset.assetnumber || '',
|
||||
printerAsset.printer?.modelname || ''
|
||||
].join(' ').toLowerCase()
|
||||
return haystack.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
// Drives the default dropdown, so the default can only ever be one of the
|
||||
// assigned printers. A printer filtered out of the list above is still here.
|
||||
const assignedPrinters = computed(() =>
|
||||
assignedPrinterAssetIds.value
|
||||
.map(assetid => printers.value.find(printerAsset => printerAsset.assetid === assetid))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||
|
||||
function isPrinterAssigned(assetid) {
|
||||
return assignedPrinterAssetIds.value.includes(assetid)
|
||||
}
|
||||
|
||||
function togglePrinter(assetid, on) {
|
||||
if (on) {
|
||||
if (!isPrinterAssigned(assetid)) {
|
||||
assignedPrinterAssetIds.value = [...assignedPrinterAssetIds.value, assetid]
|
||||
}
|
||||
} else {
|
||||
assignedPrinterAssetIds.value = assignedPrinterAssetIds.value.filter(id => id !== assetid)
|
||||
}
|
||||
}
|
||||
|
||||
// Unassigning the printer that is currently default clears the default rather
|
||||
// than leaving one pointing at a printer this PC no longer has.
|
||||
watch(assignedPrinterAssetIds, (assetids) => {
|
||||
if (defaultPrinterAssetId.value && !assetids.includes(defaultPrinterAssetId.value)) {
|
||||
defaultPrinterAssetId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// Printer list + the two relationship type ids. Both types are seed data
|
||||
// (flask seed reference-data); without them there is nothing to write, so the
|
||||
// section stays hidden rather than offering a control that cannot save.
|
||||
async function loadPrinterOptions() {
|
||||
try {
|
||||
await loadEnabledPlugins()
|
||||
if (!isPluginEnabled('printers')) return
|
||||
const [printerRows, typeResponse] = await Promise.all([
|
||||
printersApi.listAll(),
|
||||
relationshipTypesApi.list()
|
||||
])
|
||||
const types = typeResponse.data.data || []
|
||||
const typeIdFor = (name) =>
|
||||
types.find(t => t.relationshiptype === name)?.relationshiptypeid || null
|
||||
printerRelationshipTypes.value = {
|
||||
usesprinter: typeIdFor('usesprinter'),
|
||||
defaultprinter: typeIdFor('defaultprinter')
|
||||
}
|
||||
printers.value = printerRows || []
|
||||
printersEnabled.value = !!(printerRelationshipTypes.value.usesprinter
|
||||
&& printerRelationshipTypes.value.defaultprinter)
|
||||
} catch (printerError) {
|
||||
console.error('Error loading printers:', printerError)
|
||||
printersEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrinterAssignments(assetid) {
|
||||
if (!printersEnabled.value || !assetid) return
|
||||
try {
|
||||
const response = await assetsApi.getRelationships(assetid)
|
||||
const types = printerRelationshipTypes.value
|
||||
existingPrinterRelationships.value = (response.data.data?.outgoing || []).filter(
|
||||
rel => rel.relationshiptypeid === types.usesprinter
|
||||
|| rel.relationshiptypeid === types.defaultprinter
|
||||
)
|
||||
assignedPrinterAssetIds.value = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||
.map(rel => rel.targetassetid)
|
||||
const currentDefault = existingPrinterRelationships.value
|
||||
.find(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||
defaultPrinterAssetId.value = currentDefault ? currentDefault.targetassetid : null
|
||||
// Defaults set before this form existed have no usesprinter row. Show that
|
||||
// printer as assigned: a default missing from the list reads as data loss,
|
||||
// and saving then writes the row that was never there.
|
||||
if (defaultPrinterAssetId.value
|
||||
&& !assignedPrinterAssetIds.value.includes(defaultPrinterAssetId.value)) {
|
||||
assignedPrinterAssetIds.value = [
|
||||
...assignedPrinterAssetIds.value, defaultPrinterAssetId.value
|
||||
]
|
||||
}
|
||||
// /printers lists active printers only, so a retired one that is still
|
||||
// assigned would be missing from every control on this form - unable to be
|
||||
// unticked, and blank in the default box. The relationship carries the
|
||||
// asset, so add it to the list it fell out of.
|
||||
for (const rel of existingPrinterRelationships.value) {
|
||||
const target = rel.targetasset
|
||||
if (target && !printers.value.some(known => known.assetid === target.assetid)) {
|
||||
printers.value = [...printers.value, target]
|
||||
}
|
||||
}
|
||||
} catch (printerError) {
|
||||
console.error('Error loading printer assignment:', printerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile the PC's own printer rows against the picker. Row at a time
|
||||
// through the generic relationship endpoints - there is no single assignment
|
||||
// endpoint yet - so the order matters: the outgoing default goes before the
|
||||
// incoming one lands, because the unique key is (source, target, type) and
|
||||
// would let two different defaults sit side by side. Re-creating a row that
|
||||
// was removed earlier is safe; the create path reactivates the soft-deleted
|
||||
// one instead of inserting a duplicate.
|
||||
async function savePrinterAssignments(assetid) {
|
||||
const types = printerRelationshipTypes.value
|
||||
const assigned = assignedPrinterAssetIds.value
|
||||
const wanteddefault = defaultPrinterAssetId.value
|
||||
|
||||
const assignedRows = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||
const defaultRows = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||
|
||||
try {
|
||||
// Removing an assignment removes the row and nothing else. It never
|
||||
// uninstalls a queue anywhere.
|
||||
for (const rel of assignedRows) {
|
||||
if (!assigned.includes(rel.targetassetid)) {
|
||||
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||
}
|
||||
}
|
||||
for (const printerassetid of assigned) {
|
||||
if (!assignedRows.some(rel => rel.targetassetid === printerassetid)) {
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: assetid,
|
||||
targetassetid: printerassetid,
|
||||
relationshiptypeid: types.usesprinter
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const rel of defaultRows) {
|
||||
if (rel.targetassetid !== wanteddefault) {
|
||||
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||
}
|
||||
}
|
||||
if (wanteddefault && !defaultRows.some(rel => rel.targetassetid === wanteddefault)) {
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: assetid,
|
||||
targetassetid: wanteddefault,
|
||||
relationshiptypeid: types.defaultprinter
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Part of the reconcile may have landed, so what the form believes is
|
||||
// stored has to come from the server before anyone saves again.
|
||||
await loadPrinterAssignments(assetid)
|
||||
}
|
||||
}
|
||||
|
||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||
watch(() => form.value.serialnumber, (serial) => {
|
||||
@@ -689,7 +453,6 @@ onMounted(async () => {
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
loadPrinterOptions()
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
@@ -733,8 +496,6 @@ onMounted(async () => {
|
||||
levelid: pc.levelid ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
|
||||
await loadPrinterAssignments(currentAssetId.value)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
@@ -823,13 +584,16 @@ async function savePC() {
|
||||
}
|
||||
}
|
||||
|
||||
// Printer assignment through the shared picker, which reconciles the whole
|
||||
// set in one call rather than row at a time.
|
||||
//
|
||||
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
|
||||
// whose Save would create a second PC is the worse failure - but a printer
|
||||
// whose Save would create a second PC is the worse failure - but an
|
||||
// assignment that quietly did not happen is the bug this feature exists to
|
||||
// stop, so it has to be said out loud.
|
||||
if (assetId && printersEnabled.value) {
|
||||
if (assetId && printerPickerRef.value) {
|
||||
try {
|
||||
await savePrinterAssignments(assetId)
|
||||
await printerPickerRef.value.save(assetId)
|
||||
} catch (printerError) {
|
||||
console.error('Error saving printer assignment:', printerError)
|
||||
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
<!-- Site-defined custom fields for machines -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
|
||||
|
||||
<!-- Printers belong to the MACHINE, so whichever PC controls it installs
|
||||
them - including a replacement after a reimage. -->
|
||||
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="machine" />
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
@@ -366,6 +370,7 @@ import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
@@ -380,6 +385,7 @@ const isEdit = computed(() => !!route.params.id)
|
||||
// Seeded asset-type id for machines (see /api/assets/types).
|
||||
const MACHINE_ASSETTYPEID = 1
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -479,7 +485,11 @@ onMounted(async () => {
|
||||
locations.value = locRes.data.data || []
|
||||
models.value = allModels
|
||||
businessunits.value = buRes.data.data || []
|
||||
pcs.value = pcsRes.data.data || []
|
||||
// listAll resolves to the ARRAY, not a response: fetchAllPages already
|
||||
// unwrapped every page. Reading .data.data off it threw, and the whole
|
||||
// parallel load went to the catch - so every dropdown on this form came up
|
||||
// empty and the machine's own values never loaded.
|
||||
pcs.value = pcsRes || []
|
||||
|
||||
// Load relationship types separately
|
||||
try {
|
||||
@@ -633,6 +643,17 @@ async function saveMachine() {
|
||||
}
|
||||
}
|
||||
|
||||
// Printer assignment, after the asset exists so a new machine can be
|
||||
// assigned in the same save. A failure here must not lose the machine the
|
||||
// user just entered, so it is reported and not thrown.
|
||||
if (assetId && printerPickerRef.value) {
|
||||
try {
|
||||
await printerPickerRef.value.save(assetId)
|
||||
} catch (printerErr) {
|
||||
console.error('Error saving printer assignment:', printerErr)
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/machines/${savedMachine.machine?.machineid || route.params.id}`)
|
||||
} catch (err) {
|
||||
console.error('Error saving machine:', err)
|
||||
|
||||
@@ -969,6 +969,29 @@ def printers_for_host(hostname: str):
|
||||
|
||||
|
||||
|
||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_asset_printer_assignment(asset_id: int):
|
||||
"""What THIS asset is assigned, without inheritance.
|
||||
|
||||
Deliberately not resolved: an editor has to show what this asset's own rows
|
||||
say, or a machine's printers would appear ticked on the PC that inherits
|
||||
them and unticking one would silently create an override. for-host is the
|
||||
resolved view; this is the editable one.
|
||||
"""
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
if not asset or not asset.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
|
||||
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
|
||||
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
||||
return success_response({
|
||||
'assetid': asset_id,
|
||||
'printerassetids': printerassetids,
|
||||
'defaultprinterassetid': defaultassetid,
|
||||
})
|
||||
|
||||
|
||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
|
||||
@@ -529,11 +529,21 @@ def seed_reference_data():
|
||||
'isdirectional': True},
|
||||
{'relationshiptype': 'defaultprinter',
|
||||
'description': 'PC to its default printer (installer preselect, ADR-001)',
|
||||
'isdirectional': True},
|
||||
'isdirectional': True,
|
||||
# One default per asset. The unique constraint is (source, target,
|
||||
# type), so two DIFFERENT defaults are two valid rows and the resolver
|
||||
# then takes the oldest - the new default silently loses. Core's
|
||||
# create path replaces instead, driven by this flag.
|
||||
'issingular': True},
|
||||
]
|
||||
for pt in printer_types:
|
||||
if not _lookup_binary(pt['relationshiptype']):
|
||||
existing = _lookup_binary(pt['relationshiptype'])
|
||||
if not existing:
|
||||
db.session.add(RelationshipType(**pt))
|
||||
elif pt.get('issingular') and not existing.issingular:
|
||||
# A site seeded before the flag existed keeps its row and gains the
|
||||
# rule; without this the upgrade leaves the old silent behaviour.
|
||||
existing.issingular = True
|
||||
db.session.flush()
|
||||
|
||||
# Seed propagation rails as M:N rows. controls -> partof (declared;
|
||||
|
||||
@@ -792,6 +792,27 @@ def create_asset_relationship():
|
||||
http_code=409
|
||||
)
|
||||
|
||||
# A SINGULAR type allows one active row per source, and setting a new one
|
||||
# REPLACES rather than refusing: "make this the default printer" means
|
||||
# exactly that, and a card that answered 409 would leave the user to find
|
||||
# and delete the old row first.
|
||||
#
|
||||
# Without this the schema is happy to hold two defaults - the unique
|
||||
# constraint is (source, target, type), so two different targets are two
|
||||
# valid rows - and the resolver takes the OLDEST, so the new default
|
||||
# silently loses.
|
||||
reltype = db.session.get(RelationshipType, type_id)
|
||||
replaced = 0
|
||||
if reltype is not None and getattr(reltype, 'issingular', False):
|
||||
others = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == source_id,
|
||||
AssetRelationship.relationshiptypeid == type_id,
|
||||
AssetRelationship.isactive == True,
|
||||
AssetRelationship.targetassetid != target_id).all()
|
||||
for row in others:
|
||||
row.isactive = False
|
||||
replaced += 1
|
||||
|
||||
# And it cannot relate BOTH WAYS on a directional type. Only one direction
|
||||
# can be true - a PC drives a machine, never the reverse - but the check
|
||||
# above is keyed on (source, target, type), so the inverse used to insert
|
||||
|
||||
@@ -24,6 +24,11 @@ class RelationshipType(BaseModel):
|
||||
# True: edge has a source->target meaning (controls, partof, Backup For).
|
||||
# False: symmetric link (Dualpath, connectedto, USB...) shown on the card
|
||||
# once per peer with no direction, both stored direction rows collapsed.
|
||||
# At most one ACTIVE relationship of this type per source asset. A PC has
|
||||
# one default printer; the (source, target, type) unique constraint cannot
|
||||
# express that, because two different targets are two different rows.
|
||||
issingular = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
isdirectional = db.Column(
|
||||
db.Boolean,
|
||||
default=True,
|
||||
|
||||
116
tests/test_core/test_singular_relationship_types.py
Normal file
116
tests/test_core/test_singular_relationship_types.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""A relationship type that means "at most one of these per asset".
|
||||
|
||||
A PC has ONE default printer. The unique constraint on assetrelationships is
|
||||
(source, target, type), which accepts two DIFFERENT defaults quite happily - and
|
||||
the resolver takes the oldest, so setting a new default through the generic
|
||||
relationships card left the OLD one winning, with nothing to show why.
|
||||
|
||||
The rule lives on the type, not in the printers plugin: core's create path is
|
||||
where every hand-made link passes, and the next type meaning "exactly one" gets
|
||||
it for free.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, AssetRelationship, RelationshipType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene(db):
|
||||
assettype = AssetType.query.filter_by(assettype='printer').first()
|
||||
if not assettype:
|
||||
assettype = AssetType(assettype='printer', pluginname='printers',
|
||||
tablename='printers', description='p')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
pc = Asset(assetnumber='PC-SINGULAR', assettypeid=assettype.assettypeid, isactive=True)
|
||||
first = Asset(assetnumber='PRN-A', assettypeid=assettype.assettypeid, isactive=True)
|
||||
second = Asset(assetnumber='PRN-B', assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add_all([pc, first, second])
|
||||
|
||||
singular = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first()
|
||||
if not singular:
|
||||
singular = RelationshipType(relationshiptype='defaultprinter',
|
||||
description='default', isdirectional=True)
|
||||
db.session.add(singular)
|
||||
singular.issingular = True
|
||||
|
||||
plural = RelationshipType.query.filter_by(relationshiptype='usesprinter').first()
|
||||
if not plural:
|
||||
plural = RelationshipType(relationshiptype='usesprinter',
|
||||
description='installed here', isdirectional=True)
|
||||
db.session.add(plural)
|
||||
plural.issingular = False
|
||||
|
||||
db.session.commit()
|
||||
return {'pc': pc, 'first': first, 'second': second,
|
||||
'singular': singular, 'plural': plural}
|
||||
|
||||
|
||||
def _active(pc, reltype):
|
||||
return AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, relationshiptypeid=reltype.relationshiptypeid,
|
||||
isactive=True).all()
|
||||
|
||||
|
||||
def _link(client, headers, source, target, reltype):
|
||||
return client.post('/api/assets/relationships', headers=headers, json={
|
||||
'sourceassetid': source.assetid,
|
||||
'targetassetid': target.assetid,
|
||||
'relationshiptypeid': reltype.relationshiptypeid,
|
||||
})
|
||||
|
||||
|
||||
def test_setting_a_second_default_replaces_the_first(client, db, scene, auth_headers):
|
||||
"""The bug this exists for. Both POSTs succeed today and the table then
|
||||
holds two defaults."""
|
||||
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['singular']).status_code == 201
|
||||
assert _link(client, auth_headers, scene['pc'], scene['second'], scene['singular']).status_code == 201
|
||||
|
||||
rows = _active(scene['pc'], scene['singular'])
|
||||
assert len(rows) == 1
|
||||
assert rows[0].targetassetid == scene['second'].assetid
|
||||
|
||||
|
||||
def test_the_replaced_row_is_soft_deleted_not_destroyed(client, db, scene, auth_headers):
|
||||
"""Consistent with every other delete here, and it keeps the history."""
|
||||
_link(client, auth_headers, scene['pc'], scene['first'], scene['singular'])
|
||||
_link(client, auth_headers, scene['pc'], scene['second'], scene['singular'])
|
||||
|
||||
old = AssetRelationship.query.filter_by(
|
||||
sourceassetid=scene['pc'].assetid,
|
||||
targetassetid=scene['first'].assetid,
|
||||
relationshiptypeid=scene['singular'].relationshiptypeid).first()
|
||||
assert old is not None
|
||||
assert old.isactive is False
|
||||
|
||||
|
||||
def test_a_plural_type_is_untouched(client, db, scene, auth_headers):
|
||||
"""usesprinter means "installed here" and a bay has several. If the rule
|
||||
leaked to every type, assigning a second printer would remove the first."""
|
||||
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['plural']).status_code == 201
|
||||
assert _link(client, auth_headers, scene['pc'], scene['second'], scene['plural']).status_code == 201
|
||||
assert len(_active(scene['pc'], scene['plural'])) == 2
|
||||
|
||||
|
||||
def test_setting_the_same_default_twice_is_still_a_conflict(client, db, scene, auth_headers):
|
||||
"""Replacing is for a DIFFERENT target. The same link twice is the existing
|
||||
duplicate case and must keep answering 409, or the card loses its only
|
||||
signal that nothing changed."""
|
||||
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['singular']).status_code == 201
|
||||
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['singular']).status_code == 409
|
||||
assert len(_active(scene['pc'], scene['singular'])) == 1
|
||||
|
||||
|
||||
def test_another_asset_keeps_its_own_default(client, db, scene, auth_headers):
|
||||
"""The rule is per SOURCE. One PC's default must not disturb another's."""
|
||||
other = Asset(assetnumber='PC-OTHER', assettypeid=scene['pc'].assettypeid, isactive=True)
|
||||
db.session.add(other)
|
||||
db.session.commit()
|
||||
|
||||
_link(client, auth_headers, scene['pc'], scene['first'], scene['singular'])
|
||||
_link(client, auth_headers, other, scene['second'], scene['singular'])
|
||||
|
||||
assert len(_active(scene['pc'], scene['singular'])) == 1
|
||||
assert len(_active(other, scene['singular'])) == 1
|
||||
Reference in New Issue
Block a user