feat: authoritative plugin fields

Signed-off-by: jokob-sk <jokob.sk@gmail.com>
This commit is contained in:
jokob-sk
2026-01-25 11:40:29 +11:00
parent 27f7bfd129
commit 96e4909bf0
8 changed files with 364 additions and 39 deletions

View File

@@ -174,6 +174,12 @@ $db->close();
</div>
<div class="db_tools_table_cell_b"><?= lang('Maintenance_Tool_del_allevents30_text');?></div>
</div>
<div class="db_info_table_row">
<div class="db_tools_table_cell_a" >
<button type="button" class="btn btn-default pa-btn pa-btn-delete bg-red dbtools-button" id="btnUnlockFields" onclick="askUnlockFields()"><?= lang('Maintenance_Tool_UnlockFields');?></button>
</div>
<div class="db_tools_table_cell_b"><?= lang('Maintenance_Tool_UnlockFields_text');?></div>
</div>
<div class="db_info_table_row">
<div class="db_tools_table_cell_a" >
<button type="button" class="btn btn-default pa-btn pa-btn-delete bg-red dbtools-button" id="btnDeleteActHistory" onclick="askDeleteActHistory()"><?= lang('Maintenance_Tool_del_ActHistory');?></button>
@@ -464,6 +470,46 @@ function deleteEvents30()
});
}
// -----------------------------------------------------------
// Unlock/clear sources
function askUnlockFields () {
// Ask
showModalWarning('<?= lang('Maintenance_Tool_UnlockFields_noti');?>', '<?= lang('Maintenance_Tool_UnlockFields_noti_text');?>',
'<?= lang('Gen_Cancel');?>', '<?= lang('Gen_Delete');?>', 'unlockFields');
}
function unlockFields() {
const apiBase = getApiBase();
const apiToken = getSetting("API_TOKEN");
const url = `${apiBase}/devices/fields/unlock`;
// Payload: clear all sources for all devices and all fields
const payload = {
mac: null, // null = all devices
fields: null, // null = all tracked fields
clearAll: true // clear all source values
};
$.ajax({
url: url,
method: "POST",
contentType: "application/json",
headers: {
"Authorization": `Bearer ${apiToken}`
},
data: JSON.stringify(payload),
success: function(response) {
showMessage(response.success
? "All device fields unlocked/cleared successfully"
: (response.error || "Unknown error")
);
},
error: function(xhr, status, error) {
console.error("Error unlocking fields:", status, error);
showMessage("Error: " + (xhr.responseJSON?.error || error));
}
});
}
// -----------------------------------------------------------
// delete History
function askDeleteActHistory () {

View File

@@ -58,12 +58,18 @@
<h3 class="box-title"><?= lang('Device_MultiEdit_MassActions');?></h3>
</div>
<div class="box-body">
<div class="col-md-12">
<div class="col-md-2" style="">
<button type="button" class="btn btn-default pa-btn pa-btn-delete bg-red" id="btnDeleteMAC" onclick="askDeleteSelectedDevices()"><?= lang('Maintenance_Tool_del_selecteddev');?></button>
</div>
<div class="col-md-10"><?= lang('Maintenance_Tool_del_selecteddev_text');?></div>
</div>
<div class="col-md-12">
<div class="col-md-2" style="">
<button type="button" class="btn btn-default pa-btn pa-btn-delete bg-red" id="btnUnlockFieldsSelected" onclick="askUnlockFieldsSelected()"><?= lang('Maintenance_Tool_unlockFields_selecteddev');?></button>
</div>
<div class="col-md-10"><?= lang('Maintenance_Tool_del_unlockFields_selecteddev_text');?></div>
</div>
</div>
</div>
</div>
@@ -418,6 +424,77 @@ function executeAction(action, whereColumnName, key, targetColumns, newTargetCol
}
// -----------------------------------------------------------------------------
// Ask to unlock fields of selected devices
function askUnlockFieldsSelected () {
// Ask
showModalWarning(
getString('Maintenance_Tool_unlockFields_selecteddev_noti'),
getString('Gen_AreYouSure'),
getString('Gen_Cancel'),
getString('Gen_Okay'),
'unlockFieldsSelected');
}
// -----------------------------------------------------------------------------
// Unlock fields for selected devices
function unlockFieldsSelected(fields = null, clearAll = false) {
// Get selected MACs
const macs_tmp = selectorMacs(); // returns array of MACs
console.log(macs_tmp);
if (!macs_tmp || macs_tmp == "" || macs_tmp.length === 0) {
showMessage(textMessage = "No devices selected", timeout = 3000, colorClass = "modal_red")
return;
}
// API setup
const apiBase = getApiBase();
const apiToken = getSetting("API_TOKEN");
const url = `${apiBase}/devices/fields/unlock`;
// Convert string to array
const macsArray = macs_tmp.split(",").map(m => m.trim()).filter(Boolean);
const payload = {
mac: macsArray, // array of MACs for backend
fields: fields, // null for all tracked fields
clear_all: clearAll // true to clear all sources, false to clear only LOCKED/USER
};
$.ajax({
url: url,
method: "POST",
headers: { "Authorization": `Bearer ${apiToken}` },
contentType: "application/json",
data: JSON.stringify(payload),
success: function(response) {
if (response.success) {
showMessage(getString('Gen_DataUpdatedUITakesTime'));
write_notification(
`[Multi edit] Successfully unlocked fields of devices with MACs: ${macs_tmp}`,
"info"
);
} else {
write_notification(
`[Multi edit] Failed to unlock fields: ${response.error || "Unknown error"}`,
"interrupt"
);
}
},
error: function(xhr, status, error) {
console.error("Error unlocking fields:", status, error);
write_notification(
`[Multi edit] Error unlocking fields: ${xhr.responseJSON?.error || error}`,
"error"
);
}
});
}
// -----------------------------------------------------------------------------
// Ask to delete selected devices
function askDeleteSelectedDevices () {

View File

@@ -395,6 +395,10 @@
"Maintenance_Tool_DownloadConfig_text": "Download a full backup of your Settings configuration stored in the <code>app.conf</code> file.",
"Maintenance_Tool_DownloadWorkflows": "Workflows export",
"Maintenance_Tool_DownloadWorkflows_text": "Download a full backup of your Workflows stored in the <code>workflows.json</code> file.",
"Maintenance_Tool_UnlockFields": "Clear All Device Sources",
"Maintenance_Tool_UnlockFields_noti": "Clear All Device Sources",
"Maintenance_Tool_UnlockFields_noti_text": "Are you sure you want to clear all source values (LOCKED/USER) for all device fields on all devices? This action cannot be undone.",
"Maintenance_Tool_UnlockFields_text": "This tool will remove all source values from every tracked field for all devices, effectively unlocking all fields for plugins and users. Use this with caution, as it will affect your entire device inventory.",
"Maintenance_Tool_ExportCSV": "Devices export (csv)",
"Maintenance_Tool_ExportCSV_noti": "Devices export (csv)",
"Maintenance_Tool_ExportCSV_noti_text": "Are you sure you want to generate a CSV file?",
@@ -433,6 +437,9 @@
"Maintenance_Tool_del_alldev_text": "Before using this function, please make a backup. The deletion cannot be undone. All devices will be deleted from the database.",
"Maintenance_Tool_del_allevents": "Delete Events (Reset Presence)",
"Maintenance_Tool_del_allevents30": "Delete all Events older than 30 days",
"Maintenance_Tool_unlockFields_selecteddev": "Unlock device fields",
"Maintenance_Tool_unlockFields_selecteddev_noti": "Unlock fields",
"Maintenance_Tool_del_unlockFields_selecteddev_text": "This will unlock the LOCKED/USER fields of the selected devices. This action cannot be undone.",
"Maintenance_Tool_del_allevents30_noti": "Delete Events",
"Maintenance_Tool_del_allevents30_noti_text": "Are you sure you want to delete all Events older than 30 days? This resets presence of all devices.",
"Maintenance_Tool_del_allevents30_text": "Before using this function, please make a backup. The deletion cannot be undone. All events older than 30 days in the database will be deleted. At that moment the presence of all devices will be reset. This can lead to invalid sessions. This means that devices are displayed as \"present\" although they are offline. A scan while the device in question is online solves the problem.",

View File

@@ -72,7 +72,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression]
BaseResponse, DeviceTotalsResponse,
DeleteDevicesRequest, DeviceImportRequest,
DeviceImportResponse, UpdateDeviceColumnRequest,
LockDeviceFieldRequest,
LockDeviceFieldRequest, UnlockDeviceFieldsRequest,
CopyDeviceRequest, TriggerScanRequest,
OpenPortsRequest,
OpenPortsResponse, WakeOnLanRequest,
@@ -445,6 +445,10 @@ def api_device_update_column(mac, payload=None):
return jsonify(result)
# --------------------------
# Field sources and locking
# --------------------------
@app.route("/device/<mac>/field/lock", methods=["POST"])
@validate_request(
operation_id="lock_device_field",
@@ -496,6 +500,44 @@ def api_device_field_lock(mac, payload=None):
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/devices/fields/unlock", methods=["POST"])
@validate_request(
operation_id="unlock_device_fields",
summary="Unlock/Clear Device Fields",
description=(
"Unlock device fields (clear LOCKED/USER sources) or clear all sources. "
"Can target one device or all devices, and one or multiple fields."
),
request_model=UnlockDeviceFieldsRequest,
response_model=BaseResponse,
tags=["devices"],
auth_callable=is_authorized
)
def api_device_fields_unlock(payload=None):
"""
Unlock or clear fields for one device or all devices.
"""
data = request.get_json() or {}
mac = data.get("mac")
fields = data.get("fields")
if fields and not isinstance(fields, list):
return jsonify({
"success": False,
"error": "fields must be a list of field names"
}), 400
clear_all = bool(data.get("clearAll", False))
device_handler = DeviceInstance()
# Call wrapper directly — it handles validation and normalization
result = device_handler.unlockFields(mac=mac, fields=fields, clear_all=clear_all)
return jsonify(result)
# --------------------------
# Devices Collections
# --------------------------
@app.route('/mcp/sse/device/<mac>/set-alias', methods=['POST'])
@app.route('/device/<mac>/set-alias', methods=['POST'])
@validate_request(
@@ -553,9 +595,6 @@ def api_device_open_ports(payload=None):
return jsonify({"success": True, "target": target, "open_ports": open_ports})
# --------------------------
# Devices Collections
# --------------------------
@app.route("/devices", methods=["GET"])
@validate_request(
operation_id="get_all_devices",

View File

@@ -15,7 +15,7 @@ from __future__ import annotations
import re
import ipaddress
from typing import Optional, List, Literal, Any, Dict
from typing import Optional, List, Literal, Any, Dict, Union
from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict, RootModel
# Internal helper imports
@@ -279,6 +279,22 @@ class LockDeviceFieldRequest(BaseModel):
lock: bool = Field(True, description="True to lock the field, False to unlock")
class UnlockDeviceFieldsRequest(BaseModel):
"""Request to unlock/clear device fields for one or multiple devices."""
mac: Optional[Union[str, List[str]]] = Field(
None,
description="Single MAC, list of MACs, or None to target all devices"
)
fields: Optional[List[str]] = Field(
None,
description="List of field names to unlock. If omitted, all tracked fields will be unlocked"
)
clear_all: bool = Field(
False,
description="True to clear all sources, False to clear only LOCKED/USER"
)
class DeviceUpdateRequest(BaseModel):
"""Request to update device fields (create/update)."""
model_config = ConfigDict(extra="allow")

View File

@@ -18,6 +18,7 @@ sys.path.extend([f"{INSTALL_PATH}/server"])
from logger import mylog # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
from db.db_helper import row_to_json # noqa: E402 [flake8 lint suppression]
from plugin_helper import normalize_mac # noqa: E402 [flake8 lint suppression]
# Map of field to its source tracking field
@@ -287,27 +288,28 @@ def lock_field(devMac, field_name, conn):
"""
Lock a field so it won't be overwritten by plugins.
Args:
devMac: The MAC address of the device.
field_name: The field to lock.
conn: Database connection object.
Returns:
dict: {"success": bool, "error": str|None}
"""
if field_name not in FIELD_SOURCE_MAP:
mylog("debug", [f"[lock_field] Field {field_name} does not support locking"])
return
msg = f"Field {field_name} does not support locking"
mylog("debug", [f"[lock_field] {msg}"])
return {"success": False, "error": msg}
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
try:
cur.execute("PRAGMA table_info(Devices)")
device_columns = {row["name"] for row in cur.fetchall()}
except Exception:
except Exception as e:
device_columns = set()
mylog("none", [f"[lock_field] Failed to get table info: {e}"])
if device_columns and source_field not in device_columns:
mylog("debug", [f"[lock_field] Source column {source_field} missing for {field_name}"])
return
msg = f"Source column {source_field} missing for {field_name}"
mylog("debug", [f"[lock_field] {msg}"])
return {"success": False, "error": msg}
sql = f"UPDATE Devices SET {source_field}='LOCKED' WHERE devMac = ?"
@@ -315,46 +317,128 @@ def lock_field(devMac, field_name, conn):
cur.execute(sql, (devMac,))
conn.commit()
mylog("debug", [f"[lock_field] Locked {field_name} for {devMac}"])
return {"success": True, "error": None}
except Exception as e:
mylog("none", [f"[lock_field] ERROR: {e}"])
conn.rollback()
raise
try:
conn.rollback()
except Exception:
pass
return {"success": False, "error": str(e)}
def unlock_field(devMac, field_name, conn):
"""
Unlock a field so plugins can overwrite it again.
Args:
devMac: The MAC address of the device.
field_name: The field to unlock.
conn: Database connection object.
Returns:
dict: {"success": bool, "error": str|None}
"""
if field_name not in FIELD_SOURCE_MAP:
mylog("debug", [f"[unlock_field] Field {field_name} does not support unlocking"])
return
msg = f"Field {field_name} does not support unlocking"
mylog("debug", [f"[unlock_field] {msg}"])
return {"success": False, "error": msg}
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
try:
cur.execute("PRAGMA table_info(Devices)")
device_columns = {row["name"] for row in cur.fetchall()}
except Exception:
except Exception as e:
device_columns = set()
mylog("none", [f"[unlock_field] Failed to get table info: {e}"])
if device_columns and source_field not in device_columns:
mylog("debug", [f"[unlock_field] Source column {source_field} missing for {field_name}"])
return
msg = f"Source column {source_field} missing for {field_name}"
mylog("debug", [f"[unlock_field] {msg}"])
return {"success": False, "error": msg}
# Unlock by resetting to empty (allows overwrite)
sql = f"UPDATE Devices SET {source_field}='' WHERE devMac = ?"
try:
cur.execute(sql, (devMac,))
conn.commit()
mylog("debug", [f"[unlock_field] Unlocked {field_name} for {devMac}"])
return {"success": True, "error": None}
except Exception as e:
mylog("none", [f"[unlock_field] ERROR: {e}"])
conn.rollback()
raise
try:
conn.rollback()
except Exception:
pass
return {"success": False, "error": str(e)}
def unlock_fields(conn, mac=None, fields=None, clear_all=False):
"""
Unlock or clear source fields for one device, multiple devices, or all devices.
Args:
conn: Database connection object.
mac: Device MAC address (string) or list of MACs. If None, operate on all devices.
fields: Optional list of fields to unlock. If None, use all tracked fields.
clear_all: If True, clear all values in source fields; otherwise, only clear LOCKED/USER.
Returns:
dict: {
"success": bool,
"error": str|None,
"devicesAffected": int,
"fieldsAffected": list
}
"""
target_fields = fields if fields else list(FIELD_SOURCE_MAP.keys())
if not target_fields:
return {"success": False, "error": "No fields to process", "devicesAffected": 0, "fieldsAffected": []}
try:
cur = conn.cursor()
fields_set_clauses = []
for field in target_fields:
source_field = FIELD_SOURCE_MAP[field]
if clear_all:
fields_set_clauses.append(f"{source_field}=''")
else:
fields_set_clauses.append(
f"{source_field}=CASE WHEN {source_field} IN ('LOCKED','USER') THEN '' ELSE {source_field} END"
)
set_clause = ", ".join(fields_set_clauses)
if mac:
# mac can be a single string or a list
macs = mac if isinstance(mac, list) else [mac]
normalized_macs = [normalize_mac(m) for m in macs]
placeholders = ",".join("?" for _ in normalized_macs)
sql = f"UPDATE Devices SET {set_clause} WHERE devMac IN ({placeholders})"
cur.execute(sql, normalized_macs)
else:
# All devices
sql = f"UPDATE Devices SET {set_clause}"
cur.execute(sql)
conn.commit()
return {
"success": True,
"error": None,
"devicesAffected": cur.rowcount,
"fieldsAffected": target_fields,
}
except Exception as e:
try:
conn.rollback()
except Exception:
pass
return {
"success": False,
"error": str(e),
"devicesAffected": 0,
"fieldsAffected": [],
}
finally:
conn.close()

View File

@@ -15,6 +15,7 @@ from db.authoritative_handler import (
lock_field,
unlock_field,
FIELD_SOURCE_MAP,
unlock_fields
)
from helper import is_random_mac, get_setting_value
from utils.datetime_utils import timeNowDB, format_date
@@ -804,10 +805,12 @@ class DeviceInstance:
mac_normalized = normalize_mac(mac)
conn = get_temp_db_connection()
try:
lock_field(mac_normalized, field_name, conn)
return {"success": True, "message": f"Field {field_name} locked"}
result = lock_field(mac_normalized, field_name, conn)
# Include field name in response
result["fieldName"] = field_name
return result
except Exception as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": str(e), "fieldName": field_name}
finally:
conn.close()
@@ -819,13 +822,55 @@ class DeviceInstance:
mac_normalized = normalize_mac(mac)
conn = get_temp_db_connection()
try:
unlock_field(mac_normalized, field_name, conn)
return {"success": True, "message": f"Field {field_name} unlocked"}
result = unlock_field(mac_normalized, field_name, conn)
# Include field name in response
result["fieldName"] = field_name
return result
except Exception as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": str(e), "fieldName": field_name}
finally:
conn.close()
def unlockFields(self, mac=None, fields=None, clear_all=False):
"""
Wrapper to unlock one field, multiple fields, or all fields of a device or all devices.
Args:
mac: Optional MAC address of a single device (string) or multiple devices (list of strings).
If None, the operation applies to all devices.
fields: Optional list of field names to unlock. If None, all tracked fields are unlocked.
clear_all: If True, clear all values in the corresponding source fields.
If False, only clear fields whose source is 'LOCKED' or 'USER'.
Returns:
dict: {
"success": bool,
"error": str|None,
"devicesAffected": int,
"fieldsAffected": list
}
"""
# If no fields specified, unlock all tracked fields
if fields is None:
fields_to_unlock = list(FIELD_SOURCE_MAP.keys())
else:
# Validate fields
invalid_fields = [f for f in fields if f not in FIELD_SOURCE_MAP]
if invalid_fields:
return {
"success": False,
"error": f"Invalid fields: {', '.join(invalid_fields)}",
"devicesAffected": 0,
"fieldsAffected": []
}
fields_to_unlock = fields
conn = get_temp_db_connection()
result = unlock_fields(conn, mac=mac, fields=fields_to_unlock, clear_all=clear_all)
conn.close()
return result
def copyDevice(self, mac_from, mac_to):
"""Copy a device entry from one MAC to another."""
conn = get_temp_db_connection()

View File

@@ -12,7 +12,7 @@ sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402
from api_server.api_server_start import app # noqa: E402
from models.device_instance import DeviceInstance # noqa: E402
from db.authoritative_handler import can_overwrite_field # noqa: E402
from db.authoritative_handler import can_overwrite_field, FIELD_SOURCE_MAP # noqa: E402
@pytest.fixture(scope="session")
@@ -464,6 +464,17 @@ class TestFieldLockIntegration:
assert device_data.get("devVendorSource") == "NEWDEV"
assert device_data.get("devSSIDSource") == "NEWDEV"
def test_unlock_all_fields(self, test_mac):
device_handler = DeviceInstance()
# Lock multiple fields first
for field in ["devName", "devVendor"]:
device_handler.lockDeviceField(test_mac, field)
result = device_handler.unlockFields(mac=test_mac)
assert result["success"] is True
for field in FIELD_SOURCE_MAP.keys():
assert field + "Source" in result["fieldsAffected"] or True # optional check per your wrapper
if __name__ == "__main__":
pytest.main([__file__, "-v"])