diff --git a/docs/api-inventory.json b/docs/api-inventory.json index b38ee5c..7b4564a 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -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/", + "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" } ] }, diff --git a/docs/openapi.json b/docs/openapi.json index fc9b273..4e3047b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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}": { diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 3592d72..0e9e54b 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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 diff --git a/frontend/src/components/PrinterAssignmentPicker.vue b/frontend/src/components/PrinterAssignmentPicker.vue new file mode 100644 index 0000000..306097a --- /dev/null +++ b/frontend/src/components/PrinterAssignmentPicker.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/migrations/versions/7d34_singular_relationship_types.py b/migrations/versions/7d34_singular_relationship_types.py new file mode 100644 index 0000000..047d6f5 --- /dev/null +++ b/migrations/versions/7d34_singular_relationship_types.py @@ -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') diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue index b4593e1..5921fb4 100644 --- a/plugins/computers/frontend/views/PCForm.vue +++ b/plugins/computers/frontend/views/PCForm.vue @@ -245,65 +245,11 @@ - - + +
@@ -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')) diff --git a/plugins/machines/frontend/views/MachineForm.vue b/plugins/machines/frontend/views/MachineForm.vue index 198ce16..1b396df 100644 --- a/plugins/machines/frontend/views/MachineForm.vue +++ b/plugins/machines/frontend/views/MachineForm.vue @@ -344,6 +344,10 @@ + + +
{{ error }}
@@ -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) diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 6f17fa3..368676e 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -969,6 +969,29 @@ def printers_for_host(hostname: str): +@printers_asset_bp.route('/assignments/for-asset/', 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/', methods=['PUT']) @jwt_required() @require_permission('printers.edit') diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 531eda6..05f40cb 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -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; diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py index 1a0b0f9..fdbd3b4 100644 --- a/shopdb/core/api/assets.py +++ b/shopdb/core/api/assets.py @@ -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 diff --git a/shopdb/core/models/relationship.py b/shopdb/core/models/relationship.py index 9e81e6a..78b7ac2 100644 --- a/shopdb/core/models/relationship.py +++ b/shopdb/core/models/relationship.py @@ -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, diff --git a/tests/test_core/test_singular_relationship_types.py b/tests/test_core/test_singular_relationship_types.py new file mode 100644 index 0000000..6356008 --- /dev/null +++ b/tests/test_core/test_singular_relationship_types.py @@ -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