diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index d90d1c36..c455f32d 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -258,13 +258,42 @@ def _computer_access_links(comp, domain=None): return links +def _ip_comtype(): + """The 'IP' communication type, created if this site has none. + + Both the create and the update path did `if ip_comtype:` and silently did + nothing when the row was missing - so a person typed an address, saved, got + a 200, and the address was gone. A missing reference row is a seeding gap, + not a reason to discard what somebody typed. + """ + comtype = CommunicationType.query.filter_by(comtype='IP').first() + if comtype is None: + comtype = CommunicationType(comtype='IP', description='IP address') + db.session.add(comtype) + db.session.flush() + return comtype + + def _sync_access_methods(comp, data): """Replace a computer's enabled protocols from data['accessmethods'] (a list of {protocolid, portoverride?}). No-op if the key is absent, so callers that - don't touch access aren't affected.""" + don't touch access aren't affected. + + Returns a {'old': [...], 'new': [...]} change entry when the set actually + moved, or None. The caller records it: which remote-access protocols reach + a shop-floor PC, and on which ports, is exactly the kind of change an audit + log exists to remember, and this wrote nothing at all. + """ if 'accessmethods' not in data: - return + return None desired = data.get('accessmethods') or [] + + def describe(rows): + return sorted('%s:%s' % (row.protocolid, row.portoverride or 'default') + for row in rows) + + before = describe(ComputerAccess.query.filter_by( + computerid=comp.computerid).all()) ComputerAccess.query.filter_by(computerid=comp.computerid).delete() seen = set() for m in desired: @@ -287,6 +316,13 @@ def _sync_access_methods(comp, data): isactive=True, )) + db.session.flush() + after = describe(ComputerAccess.query.filter_by( + computerid=comp.computerid).all()) + if before != after: + return {'old': before, 'new': after} + return None + # ============================================================================= # Computers CRUD @@ -587,7 +623,7 @@ def create_computer(): # Optional primary IP communication if data.get('ipaddress'): - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + ip_comtype = _ip_comtype() if ip_comtype: db.session.add(Communication( assetid=asset.assetid, @@ -596,7 +632,9 @@ def create_computer(): isprimary=True, )) - # Remote-access protocols + # Remote-access protocols. The return value is not recorded here: a create + # is already described in full by its 'created' entry, and there is no + # previous state to compare against. _sync_access_methods(comp, data) # Preserve legacy timestamps in import mode (no-op otherwise) @@ -697,20 +735,27 @@ def update_computer(computer_id: int): ip = (data.get('ipaddress') or '').strip() primary = Communication.query.filter_by( assetid=asset.assetid, isprimary=True).first() + # Recorded, like every other field on this form. The address a PC + # answers on is how somebody finds it later; changing it wrote no audit + # entry at all, so a machine that moved had no history of moving. + oldip = primary.ipaddress if primary else None + if (oldip or None) != (ip or None): + changes['ipaddress'] = {'old': oldip, 'new': ip or None} if ip: if primary: primary.ipaddress = ip else: - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - if ip_comtype: - db.session.add(Communication( - assetid=asset.assetid, comtypeid=ip_comtype.comtypeid, - ipaddress=ip, isprimary=True)) + db.session.add(Communication( + assetid=asset.assetid, + comtypeid=_ip_comtype().comtypeid, + ipaddress=ip, isprimary=True)) elif primary: primary.ipaddress = None # Remote-access protocols - _sync_access_methods(comp, data) + accesschange = _sync_access_methods(comp, data) + if accesschange: + changes['accessmethods'] = accesschange # Audit log if there were changes if changes: diff --git a/plugins/computers/frontend/views/BusinessPoliciesList.vue b/plugins/computers/frontend/views/BusinessPoliciesList.vue index e2f3b971..db4d7a4e 100644 --- a/plugins/computers/frontend/views/BusinessPoliciesList.vue +++ b/plugins/computers/frontend/views/BusinessPoliciesList.vue @@ -2,7 +2,14 @@
+ +
+ Site policies are not enabled on this site. +

Nothing can be created here until the feature is turned on. + The list below is empty because the feature is off, not + because nothing has been configured.

@@ -110,6 +117,7 @@ const toast = useToast() const policies = ref([]) const configPolicies = ref([]) const loading = ref(true) +const featureOff = ref(false) const showModal = ref(false) const editing = ref(null) @@ -135,6 +143,10 @@ async function loadData() { configPolicies.value = config.data.data || [] } catch (err) { console.error('Error loading business policies:', err) + // The API answers 404 'Policy feature is not enabled' when the + // flag is off. Without this the page rendered its empty table + // and an Add button for a feature that cannot accept one. + featureOff.value = err?.response?.status === 404 } finally { loading.value = false } @@ -214,6 +226,13 @@ async function remove(p) {