diff --git a/front/maintenance.php b/front/maintenance.php index f7bc0d04..d9f314a6 100755 --- a/front/maintenance.php +++ b/front/maintenance.php @@ -174,6 +174,12 @@ $db->close();
+
+
+ +
+
+
@@ -464,6 +470,46 @@ function deleteEvents30() }); } +// ----------------------------------------------------------- +// Unlock/clear sources +function askUnlockFields () { + // Ask + showModalWarning('', '', + '', '', '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 () { diff --git a/front/multiEditCore.php b/front/multiEditCore.php index 4dd4fbeb..6006b6db 100755 --- a/front/multiEditCore.php +++ b/front/multiEditCore.php @@ -58,12 +58,18 @@

- +
- +
+
+
+ +
+
+
@@ -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 () { diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index e5c76dec..2fd76d85 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -395,6 +395,10 @@ "Maintenance_Tool_DownloadConfig_text": "Download a full backup of your Settings configuration stored in the app.conf file.", "Maintenance_Tool_DownloadWorkflows": "Workflows export", "Maintenance_Tool_DownloadWorkflows_text": "Download a full backup of your Workflows stored in the workflows.json 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.", diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index e509ca28..eefdb77a 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -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//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//set-alias', methods=['POST']) @app.route('/device//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", diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index e8fe6423..720c588d 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -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") diff --git a/server/db/authoritative_handler.py b/server/db/authoritative_handler.py index aa661b8e..b5e0ed6d 100644 --- a/server/db/authoritative_handler.py +++ b/server/db/authoritative_handler.py @@ -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() + diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 9872b965..3b243a74 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -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() diff --git a/test/authoritative_fields/test_device_field_lock.py b/test/authoritative_fields/test_device_field_lock.py index 8cdcdbc8..168d6875 100644 --- a/test/authoritative_fields/test_device_field_lock.py +++ b/test/authoritative_fields/test_device_field_lock.py @@ -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"])