Skip to content

Conversation

@jokob-sk
Copy link
Collaborator

@jokob-sk jokob-sk commented Jan 19, 2026

Authoritative overwrite checks should be implemented inside SQL UPDATE conditions wherever possible, avoiding per-row Python logic.

A plugin may overwrite a field if:

Plugins must provide a non-empty value for an overwrite to occur.
the field is empty, OR
the field’s Source is neither USER nor LOCKED, AND
the plugin’s SET_ALWAYS / SET_EMPTY rules allow it.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added device field locking capability to prevent automatic plugin overwrites of manual edits
    • Added source indicators showing where each device field value originated
    • Added primary IPv4/IPv6 address tracking for devices
  • Documentation

    • Added comprehensive guides for field lock/unlock functionality and API integration
  • Improvements

    • Updated UI labels for clarity ("Device Information," "Network Details")
    • Enhanced device save feedback with status messages

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 19, 2026

📝 Walkthrough

Walkthrough

Introduces a device field lock/unlock feature with authoritative source-tracking to prevent plugins from overwriting manually set device values. Adds database schema columns, backend authorization logic, frontend UI elements, API endpoints, plugin configurations, and comprehensive test coverage.

Changes

Cohort / File(s) Summary
Database Schema
back/app.sql, server/database.py, server/db/db_upgrade.py
Added columns to Devices table: devPrimaryIPv4, devPrimaryIPv6, devVlan, devForceStatus for new metadata, plus 10 source-tracking columns (devMacSource, devNameSource, etc.) to track field value origins. Added cur_devVlan to CurrentScan. Updated schema validation in db_upgrade.
Authoritative Handler
server/db/authoritative_handler.py
New module implementing field source tracking and locking logic. Provides functions: can_overwrite_field() (permission checks), lock_field()/unlock_field() (state management), get_source_for_field_update() (source determination), and get_plugin_authoritative_settings() (plugin configuration retrieval).
Device Model & Instance
server/models/device_instance.py
Added lockDeviceField() and unlockDeviceField() instance methods. Integrated enforce_source_on_user_update() to track USER source when fields are updated manually.
API Endpoints
server/api_server/api_server_start.py, server/api_server/graphql_endpoint.py, server/api_server/openapi/schemas.py
Added api_device_field_lock() endpoint for lock/unlock operations. Extended DeviceInfo model with devPrimaryIPv4, devPrimaryIPv6, devVlan, devForceStatus fields and 10 source-tracking fields. Introduced LockDeviceFieldRequest schema.
Scan & Device Handling
server/scan/device_handling.py, server/scan/session_events.py, server/const.py
Updated device creation and IP handling to populate devPrimaryIPv4/devPrimaryIPv6 based on IP family. Refined IP change detection and event filtering. Added new columns to device query selection.
Frontend UI
front/deviceDetailsEdit.php, front/css/app.css
Added field lock toggle buttons and source indicators in device details. Introduced toggleFieldLock() function for lock/unlock API calls. Added CSS styling for lock buttons, source badges, and field state indicators.
Plugin Configurations
front/plugins/*/config.json (arp_scan, asuswrt_import, avahi_scan, dhcp_leases, dig_scan, ipneigh, luci_import, nbtscan_scan, newdev_template, nslookup_scan, pihole_api_scan, pihole_scan, snmp_discovery, unifi_api_import, unifi_import, vendor_update)
Added SET_ALWAYS and SET_EMPTY configuration options to 16 plugins, defining which columns are always/conditionally overwritten. Newdev_template also includes devPrimaryIPv4, devPrimaryIPv6, devVlan, devForceStatus settings.
Translations
front/php/templates/language/*.json (ar_ar, ca_ca, cs_cz, de_de, en_us, es_es, fa_fa, fr_fr, it_it, ja_jp, nb_no, pl_pl, pt_br, pt_pt, ru_ru, sv_sv, tr_tr, uk_ua, zh_cn)
Added translation keys for device save outcomes (Device_Save_Failed, Device_Save_Unauthorized, Device_Saved_Success, Device_Saved_Unexpected) and field lock UI (FieldLock_Error, FieldLock_Lock_Tooltip, FieldLock_Locked, FieldLock_Source_Label, FieldLock_Unlock_Tooltip, FieldLock_Unlocked). English translations provided; others empty.
Documentation
docs/API_DEVICE_FIELD_LOCK.md, docs/DEVICE_MANAGEMENT.md, docs/PLUGINS_DEV_CONFIG.md, docs/QUICK_REFERENCE_FIELD_LOCK.md, mkdocs.yml
Added comprehensive documentation on field locking feature, API usage, plugin configuration (SET_ALWAYS/SET_EMPTY), quick reference guide, and navigation entries.
Test Suite
test/authoritative_fields/FIELD_LOCK_TEST_SUMMARY.md, test_authoritative_handler.py, test_field_lock_scan_integration.py, test_field_lock_scenarios.py, test_ip_format_and_locking.py, test_ip_update_logic.py, test_device_field_lock.py
Added 33+ unit and integration tests covering: can_overwrite_field() logic, scan-based field updates, IP format handling, field lock state transitions, API endpoint behavior, and end-to-end locking scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant User as User Interface
    participant API as Backend API
    participant Auth as Authoritative Handler
    participant DB as Database
    participant Plugin as Plugin (Update)

    User->>API: Lock field (devName)
    API->>Auth: lock_field(mac, "devName")
    Auth->>DB: UPDATE Devices SET devNameSource='LOCKED'
    DB-->>Auth: Success
    Auth-->>API: Lock confirmed

    Plugin->>API: Update device (devName='NewName')
    API->>Auth: can_overwrite_field("devName", "LOCKED", plugin_prefix)
    Auth-->>API: False (field locked)
    API-->>Plugin: Field not updated (already at source value)

    User->>API: Unlock field (devName)
    API->>Auth: unlock_field(mac, "devName")
    Auth->>DB: UPDATE Devices SET devNameSource=''
    DB-->>Auth: Success
    Auth-->>API: Unlock confirmed

    Plugin->>API: Update device (devName='NewName')
    API->>Auth: can_overwrite_field("devName", "", plugin_prefix)
    Auth-->>API: True (field unlocked, source empty)
    API->>DB: UPDATE Devices SET devName='NewName', devNameSource='PLUGIN_PREFIX'
    DB-->>API: Success
Loading

Possibly related PRs

  • PR #1326: Modifies DeviceInstance and API endpoints (server/api_server/api_server_start.py, server/models/device_instance.py), directly intersecting with field locking implementation at the code level.

Poem

🐰 Whiskers twitch with glee

Fields locked tight as carrots three,
Plugins pause, let users decree—
Sources tracked in harmony,
No more overwrites run free!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Authoritative plugin fields' clearly relates to the core changes in this PR, which implement an authoritative field update system with source tracking and field locking for plugin overwrites.
Docstring Coverage ✅ Passed Docstring coverage is 91.23% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
server/models/device_instance.py (1)

593-609: Make the update + source enforcement atomic.

get_temp_db_connection() uses isolation_level=None, enabling SQLite autocommit mode. In autocommit, cur.execute(sql, values) at line 595 commits immediately before enforce_source_on_user_update() runs at line 604. If enforcement fails, the attempted conn.rollback() cannot undo the already-committed UPDATE, leaving device data and source tracking out of sync. Wrap both the UPDATE and enforcement in an explicit transaction using BEGIN IMMEDIATE to ensure both succeed or both fail atomically.

🧩 Proposed fix
-            cur = conn.cursor()
-            cur.execute(sql, values)
-            conn.commit()
+            conn.execute("BEGIN IMMEDIATE")
+            cur = conn.cursor()
+            cur.execute(sql, values)
 
             # Enforce source tracking on user updates
             # User-updated fields should have their *Source set to "USER"
             user_updated_fields = {k: v for k, v in data.items() if k in FIELD_SOURCE_MAP}
             if user_updated_fields and not data.get("createNew", False):
                 try:
                     enforce_source_on_user_update(normalized_mac, user_updated_fields, conn)
                 except Exception as e:
                     mylog("none", [f"[DeviceInstance] Failed to enforce source tracking: {e}"])
                     conn.rollback()
                     conn.close()
                     return {"success": False, "error": f"Source tracking failed: {e}"}
+
+            conn.commit()
server/api_server/api_server_start.py (2)

65-76: Fix source-field resolution; concatenation breaks several columns.

field_name + "Source" won’t match devLastIpSource, devFqdnSource, devSsidSource, or devParentMacSource. Use FIELD_SOURCE_MAP to avoid mismatches and keep the allowlist in sync.

🐛 Proposed fix
-from .openapi.schemas import (  # noqa: E402 [flake8 lint suppression]
+from .openapi.schemas import (  # noqa: E402 [flake8 lint suppression]
     DeviceSearchRequest, DeviceSearchResponse,
@@
     RecentEventsRequest, SetDeviceAliasRequest
 )
+from db.authoritative_handler import FIELD_SOURCE_MAP  # noqa: E402 [flake8 lint suppression]
@@
-    source_field = field_name + "Source"
-    allowed_tracked_fields = {
-        "devMac", "devName", "devLastIP", "devVendor", "devFQDN", 
-        "devSSID", "devParentMAC", "devParentPort", "devParentRelType", "devVlan"
-    }
-    if field_name not in allowed_tracked_fields:
+    source_field = FIELD_SOURCE_MAP.get(field_name)
+    if not source_field:
         return jsonify({"success": False, "error": f"Field '{field_name}' cannot be locked"}), 400

Also applies to: 472-479


47-47: Normalize MAC before the DB write.

This endpoint writes device data but doesn’t validate/normalize the MAC before calling updateDeviceColumn.

✅ Proposed fix
-from plugin_helper import is_mac  # noqa: E402 [flake8 lint suppression]
+from plugin_helper import is_mac, normalize_mac  # noqa: E402 [flake8 lint suppression]
@@
 def api_device_field_lock(mac, payload=None):
@@
-    device_handler = DeviceInstance()
+    normalized_mac = normalize_mac(mac)
+    if not normalized_mac:
+        return jsonify({"success": False, "error": "Invalid MAC address"}), 400
+
+    device_handler = DeviceInstance()
@@
-        result = device_handler.updateDeviceColumn(mac, source_field, new_source)
+        result = device_handler.updateDeviceColumn(normalized_mac, source_field, new_source)

As per coding guidelines, please validate MAC addresses with normalize_mac() before database writes.

Also applies to: 463-488

🤖 Fix all issues with AI agents
In `@front/php/templates/language/sv_sv.json`:
- Around line 300-305: The Swedish translation file contains empty values for
the FieldLock_* keys (FieldLock_Error, FieldLock_Lock_Tooltip, FieldLock_Locked,
FieldLock_Source_Label, FieldLock_Unlock_Tooltip, FieldLock_Unlocked) which
results in blank UI because fallback only checks key existence; either provide
proper Swedish translations for those keys (replace empty strings with localized
text) or remove these keys from the sv_sv JSON (or remove sv_sv from the
available languages) so the app can fall back to English—update the keys in the
same JSON object where FieldLock_* are defined and ensure no empty string values
remain.

In `@front/plugins/dig_scan/config.json`:
- Around line 85-87: The JSON settings array is invalid because object entries
are missing commas between them; add a trailing comma after each settings object
where you see entries like { "function": "SET_ALWAYS", "type": ... } and the
neighboring object literals (also at the other occurrences around the sections
referenced near the second and third SET_ALWAYS blocks) so each object in the
array is separated by a comma, ensuring the arrays containing
"function":"SET_ALWAYS" and adjacent objects are properly comma-delimited.

In `@front/plugins/luci_import/config.json`:
- Around line 571-632: The JSON entries for "SET_ALWAYS" and "SET_EMPTY" are
currently placed under database_column_definitions but are settings schema
entries and should be moved into the manifest's settings array; locate the
objects with "function": "SET_ALWAYS" and "function": "SET_EMPTY" and remove
them from database_column_definitions, then re-add them into the settings array
(for example after "RUN_TIMEOUT") so the plugin reads them via
get_setting_value(); ensure their schema (type, default_value, options,
localized/name/description) is preserved exactly when relocating.

In `@front/plugins/pihole_scan/config.json`:
- Around line 219-344: There are duplicate configuration blocks for the
functions "SET_ALWAYS" and "SET_EMPTY" which creates ambiguity; remove the first
pair (the blocks whose default_value is ["devLastIP"] and [] respectively) and
keep a single authoritative "SET_ALWAYS" block with default_value
["devMac","devLastIP"] and a single "SET_EMPTY" block with default_value
["devName","devVendor"], ensuring each retained block's options include
["devMac","devLastIP","devName","devVendor"] and keep their localized
name/description entries intact.

In `@front/plugins/unifi_api_import/config.json`:
- Around line 42-44: The JSON is invalid due to stray closing tokens after the
"params" property; locate the object that contains the "params" key in
front/plugins/unifi_api_import/config.json (the entry showing `"params": []`)
and remove the extra trailing `}` and `]` shown after that property so the
surrounding object/array syntax is correct (ensure no dangling commas remain and
the enclosing object/array are properly closed).

In `@server/api_server/api_server_start.py`:
- Around line 499-501: Replace the illegal mylog("error", ...) call in the
exception handler with one of the allowed levels
(none/minimal/verbose/debug/trace) and pass the message as a list payload per
the logging contract; specifically update the catch block that references
field_name and mac to call e.g. mylog("minimal", [f"Error locking field
{field_name} for {mac}", str(e)]) and then return the same jsonify response —
ensure the first argument is one of the permitted level strings and the second
argument is a list containing the descriptive message and the error detail.

In `@server/models/device_instance.py`:
- Around line 681-705: Both lockDeviceField and unlockDeviceField must normalize
the mac and always close the DB connection; update lockDeviceField and
unlockDeviceField to call normalize_mac(mac) before using it with
lock_field/unlock_field (and still validate field_name against
FIELD_SOURCE_MAP), obtain conn via get_temp_db_connection(), run
lock_field(mac_normalized, field_name, conn) / unlock_field(mac_normalized,
field_name, conn) inside try, and move conn.close() into a finally block so the
connection is closed whether an exception occurs; preserve the same
success/error return shapes.

In `@server/scan/device_handling.py`:
- Around line 544-548: The IPv6 branch currently bypasses validation (cur_IP is
passed through when ":" in cur_IP), so add IPv6 validation consistent with IPv4
handling by reusing or mirroring the logic in format_ip_long from
server/helper.py: when cur_IP contains ":", call the IPv6 validator (e.g.,
format_ip_long or a new helper wrapper) to normalize/validate the address and
only assign primary_ipv6 if the validator returns a valid normalized value; keep
primary_ipv4 assignment unchanged and ensure invalid IPv6 values result in an
empty primary_ipv6 string (or the same error/empty behavior as invalid IPv4 via
check_IP_format).

In `@test/authoritative_fields/test_field_lock_scan_integration.py`:
- Around line 689-702: The test has an IndentationError because the results dict
and following for loop are over-indented; adjust indentation so the variable
results and the for mac, expected in results.items(): loop align with
surrounding test code (same block level as other setup/assert sections) and
ensure the cur.execute(...) call and its fetchone() remain inside the loop body;
update the block around results, for loop, and the assertion that references
cur.execute, devName/devVendor to match the file's surrounding indentation and
remove the extra leading spaces.
🟡 Minor comments (12)
front/php/templates/language/it_it.json-206-209 (1)

206-209: Provide Italian (or fallback) text for Device_Save_ strings.*

These are empty, so success/error messages will render blank in the UI. Please add translations or copy the en_us strings as a temporary fallback.

front/php/templates/language/it_it.json-300-305 (1)

300-305: Fill FieldLock_ tooltips/status labels (currently empty).*

Empty values will hide lock/unlock tooltips and status labels for Italian users. Please translate or fallback to en_us to avoid blank UI.

front/php/templates/language/pt_br.json-300-305 (1)

300-305: Missing Portuguese translations for field lock feature.

These new keys have empty placeholder values for the field locking UI. Consider adding proper translations such as:

  • FieldLock_Error: "Erro ao bloquear campo"
  • FieldLock_Lock_Tooltip: "Clique para bloquear este campo"
  • FieldLock_Locked: "Bloqueado"
  • FieldLock_Source_Label: "Fonte"
  • FieldLock_Unlock_Tooltip: "Clique para desbloquear este campo"
  • FieldLock_Unlocked: "Desbloqueado"
front/php/templates/language/pt_br.json-206-209 (1)

206-209: Missing Portuguese translations for device save messages.

These new keys have empty placeholder values. Without translations, Portuguese-speaking users will see blank or fallback text when device save operations succeed or fail. Consider adding proper translations such as:

  • Device_Save_Failed: "Falha ao salvar dispositivo"
  • Device_Save_Unauthorized: "Não autorizado a salvar dispositivo"
  • Device_Saved_Success: "Dispositivo salvo com sucesso"
  • Device_Saved_Unexpected: "Erro inesperado ao salvar dispositivo"
front/php/templates/language/pl_pl.json-300-305 (1)

300-305: Empty Polish translations for field lock feature.

The field lock UI elements lack Polish translations. Users will see empty tooltips and status labels for the new field locking functionality.

Suggested translations:

"FieldLock_Error": "Błąd blokady pola",
"FieldLock_Lock_Tooltip": "Zablokuj to pole",
"FieldLock_Locked": "Zablokowane",
"FieldLock_Source_Label": "Źródło",
"FieldLock_Unlock_Tooltip": "Odblokuj to pole",
"FieldLock_Unlocked": "Odblokowane",
front/php/templates/language/pl_pl.json-206-209 (1)

206-209: Empty Polish translations for device save messages.

These translation keys have empty placeholder values. Polish users will see empty text or fallback behavior for device save status messages until translations are provided.

Suggested translations:

"Device_Save_Failed": "Zapisanie urządzenia nie powiodło się",
"Device_Save_Unauthorized": "Brak autoryzacji do zapisania urządzenia",
"Device_Saved_Success": "Urządzenie zapisane pomyślnie",
"Device_Saved_Unexpected": "Nieoczekiwany błąd podczas zapisywania urządzenia",
front/php/templates/language/sv_sv.json-206-209 (1)

206-209: Add Swedish strings for these keys or confirm that empty values reliably fall back to the default locale.

New Device_Save_* and FieldLock_* keys have empty string values in the Swedish translation file. Without a confirmed fallback mechanism, these will display as blank UI text. Either provide Swedish translations or verify that your i18n system handles empty values by falling back to English.

docs/PLUGINS_DEV_CONFIG.md-180-259 (1)

180-259: Docs examples don’t match the config schema used in this PR.

The examples show per-field "SET_ALWAYS": "1" / "SET_EMPTY": "1", but the manifests here define SET_ALWAYS/SET_EMPTY as settings arrays of column names. This will confuse plugin authors. Consider updating the examples and decision flow to reflect the actual manifest format.

docs/API_DEVICE_FIELD_LOCK.md-136-148 (1)

136-148: Fix inconsistent LOCKED color description.
You describe LOCKED as orange above, but the workflow says it turns red. Please align to one color.

✏️ Suggested fix
-4. Button changes to unlock (🔓) and source indicator turns red (LOCKED)
+4. Button changes to unlock (🔓) and source indicator turns orange (LOCKED)
test/authoritative_fields/test_ip_update_logic.py-84-156 (1)

84-156: Use @pytest.mark.usefixtures for side-effect-only fixtures.

The mock_device_handling fixture is applied only for its patching side effects and never referenced in the test body. Ruff flags it as ARG001 (unused argument). Follow pytest conventions by moving it to a decorator.

🔧 Proposed fix
+@pytest.mark.usefixtures("mock_device_handling")
-def test_primary_ipv6_is_set_and_ipv4_preserved(in_memory_db, mock_device_handling):
+def test_primary_ipv6_is_set_and_ipv4_preserved(in_memory_db):
     """Setting IPv6 in CurrentScan should update devPrimaryIPv6 without changing devPrimaryIPv4."""

Also applies to: test_primary_ipv4_is_set_and_ipv6_preserved

test/authoritative_fields/FIELD_LOCK_TEST_SUMMARY.md-279-282 (1)

279-282: Align status count with “Total Tests: 33.”

The status line states “24 comprehensive tests,” which conflicts with the earlier total of 33.

📝 Proposed fix
-**Status:** ✅ 24 comprehensive tests created covering all scenarios
+**Status:** ✅ 33 comprehensive tests created covering all scenarios
front/deviceDetailsEdit.php-597-605 (1)

597-605: Source indicator not updated when unlocking a field.

When shouldLock is false (unlocking), the source indicator text and styling are not updated. The user will see stale "LOCKED" state until page refresh.

🐛 Proposed fix to update source indicator on unlock
         // Update source indicator if locked
         if (shouldLock) {
           const sourceIndicator = lockBtn.next();
           if (sourceIndicator.hasClass("input-group-addon")) {
             sourceIndicator.text("LOCKED");
             sourceIndicator.attr("class", "input-group-addon text-danger");
             sourceIndicator.attr("title", getString("FieldLock_Source_Label") + "LOCKED");
           }
+        } else {
+          // Update source indicator on unlock (source reverts to previous state, typically NEWDEV)
+          const sourceIndicator = lockBtn.next();
+          if (sourceIndicator.hasClass("input-group-addon")) {
+            sourceIndicator.html('<i class="fa-solid fa-tag"></i> NEWDEV');
+            sourceIndicator.attr("class", "input-group-addon text-muted");
+            sourceIndicator.attr("title", getString("FieldLock_Source_Label") + "NEWDEV");
+          }
         }

Note: The actual source after unlocking may vary (could be NEWDEV or the previous source). Consider fetching the updated source from the API response for accuracy.

🧹 Nitpick comments (19)
front/php/templates/language/nb_no.json (1)

778-778: Consider adding a trailing newline.

The file ends without a trailing newline. Adding one improves compatibility with POSIX tools and avoids "no newline at end of file" warnings in diffs.

front/php/templates/language/cs_cz.json (1)

206-209: Empty translations for new Device save status keys.

These new keys follow the existing pattern of placeholder translations. Consider adding Czech translations before release to ensure a consistent localized user experience, or verify that empty strings fall back gracefully to a default language.

server/db/db_upgrade.py (2)

86-96: Guard blocks schema upgrades for non-Devices tables due to empty list

The guard at line 88 uses an empty list [] as the fallback for non-Devices tables. Since [] is falsy in Python, the condition if not expected or column_name not in expected: will always trigger (returning False) when the function is called with any table other than "Devices". While this is currently not an issue since ensure_column is only called with "Devices" (37 call sites in server/database.py), the overly restrictive logic makes the function unsuitable for evolution of other tables. Consider checking if expected is not None and column_name not in expected: instead, so the validation only applies when a table has defined expected columns.


263-277: Remove unused cur_devVlan column or align naming with table pattern

The cur_devVlan column in the CurrentScan table definition breaks the consistent cur_* naming pattern (e.g., cur_MAC, cur_IP, cur_SSID). However, this column is never populated in any INSERT statements and never referenced in SELECT queries, making it dead code. Either remove the column or rename it to cur_Vlan to match the naming convention used throughout the table.

front/css/app.css (1)

2442-2473: Consider reusing existing color CSS variables for lock indicators.
Keeps palette consistent and reduces duplicate hex values.

♻️ Suggested tweak
 .input-group-addon.text-warning {
-    color: `#f39c12`;
+    color: var(--color-yellow);
     background-color: rgba(243, 156, 18, 0.1);
 }
 
 .input-group-addon.text-danger {
-    color: `#dd4b39`;
+    color: var(--color-red);
     background-color: rgba(221, 75, 57, 0.1);
 }
 
 .input-group-addon.text-muted {
-    color: `#8c8c8c`;
+    color: var(--color-gray);
     background-color: rgba(140, 140, 140, 0.05);
 }
docs/QUICK_REFERENCE_FIELD_LOCK.md (1)

86-88: Tighten wording to avoid repeated “again”.

✏️ Suggested tweak
- ✅ You can lock it again anytime
+ ✅ You can lock it anytime
server/api_server/api_server_start.py (1)

463-468: Use the validated payload to avoid bypassing schema checks.

payload is unused, which bypasses the LockDeviceFieldRequest validation and triggers ARG001.

♻️ Proposed refactor
-    data = request.get_json() or {}
+    data = payload if payload is not None else (request.get_json() or {})
+    if hasattr(data, "model_dump"):
+        data = data.model_dump(exclude_unset=True)
+    elif hasattr(data, "dict"):
+        data = data.dict(exclude_unset=True)
test/test_device_field_lock.py (3)

42-58: Consider logging cleanup failures for debugging.

The cleanup fixture silently swallows all exceptions. While this is acceptable for test teardown, logging the exception could help diagnose test environment issues.

♻️ Suggested improvement
 `@pytest.fixture`(autouse=True)
 def cleanup_test_device(test_mac):
     """Clean up test device before and after test."""
     device_handler = DeviceInstance()
     # Clean before test
     try:
         device_handler.deleteDeviceByMAC(test_mac)
-    except Exception:
-        pass
+    except Exception as e:
+        print(f"[cleanup] Pre-test cleanup note: {e}")
     
     yield
     
     # Clean after test
     try:
         device_handler.deleteDeviceByMAC(test_mac)
-    except Exception:
-        pass
+    except Exception as e:
+        print(f"[cleanup] Post-test cleanup note: {e}")

272-290: Remove unused auth_headers parameter.

The auth_headers fixture is not used in this test since it interacts directly with DeviceInstance rather than the API client.

♻️ Suggested fix
-    def test_locked_field_blocks_plugin_overwrite(self, test_mac, auth_headers):
+    def test_locked_field_blocks_plugin_overwrite(self, test_mac):
         """Verify locked fields prevent plugin source overwrites."""
         device_handler = DeviceInstance()

292-316: Remove unused auth_headers parameter.

Same as above - this integration test uses DeviceInstance directly.

♻️ Suggested fix
-    def test_field_source_tracking(self, test_mac, auth_headers):
+    def test_field_source_tracking(self, test_mac):
         """Verify field source is tracked correctly."""
         device_handler = DeviceInstance()
front/plugins/snmp_discovery/config.json (1)

589-650: Missing localization for non-English languages.

The SET_ALWAYS and SET_EMPTY settings only include en_us translations, while other settings in this plugin (e.g., RUN, CMD, RUN_SCHD) include es_es and de_de translations. Consider adding translations for consistency.

front/plugins/nbtscan_scan/config.json (3)

86-86: JSON formatting inconsistency.

Line 86 has inconsistent formatting with extra spaces before "function". This appears to be a merge artifact. While valid JSON, it breaks the file's formatting consistency.

Suggested fix
-    {      "function": "SET_ALWAYS",
+    {
+      "function": "SET_ALWAYS",

145-145: Same formatting issue on CMD block.

Suggested fix
-    {      "function": "CMD",
+    {
+      "function": "CMD",

86-144: Missing localization for non-English languages.

Similar to snmp_discovery, the SET_ALWAYS and SET_EMPTY settings only include en_us translations, while other settings in this plugin include es_es and de_de translations.

server/api_server/openapi/schemas.py (1)

276-280: Consider validating fieldName against allowed lockable fields.

The fieldName field accepts any string, but only certain fields support locking (those in FIELD_SOURCE_MAP in authoritative_handler.py). Consider adding validation to provide better error messages at the schema level.

Suggested improvement
+# At the top of the file, define allowed lockable fields
+ALLOWED_LOCKABLE_FIELDS = Literal[
+    "devMac", "devName", "devFQDN", "devLastIP", "devVendor",
+    "devSSID", "devParentMAC", "devParentPort", "devParentRelType", "devVlan"
+]
+
 class LockDeviceFieldRequest(BaseModel):
     """Request to lock/unlock a device field."""
-    fieldName: str = Field(..., description="Field name to lock/unlock (devMac, devName, devLastIP, etc.)")
+    fieldName: ALLOWED_LOCKABLE_FIELDS = Field(..., description="Field name to lock/unlock")
     lock: bool = Field(True, description="True to lock the field, False to unlock")
server/db/authoritative_handler.py (4)

18-19: Remove unused noqa directives.

Static analysis indicates these noqa: E402 directives are not needed. The E402 rule (module level import not at top of file) is either not enabled or not triggered here.

Suggested fix
-from logger import mylog  # noqa: E402 [flake8 lint suppression]
-from helper import get_setting_value  # noqa: E402 [flake8 lint suppression]
+from logger import mylog
+from helper import get_setting_value

75-117: Unused plugin_prefix parameter.

The plugin_prefix parameter is declared but never used in can_overwrite_field. Consider either using it for logging/debugging or removing it from the signature. Based on learnings from relevant code snippets, logging could help troubleshoot overwrite decisions.

Option 1: Remove unused parameter
-def can_overwrite_field(field_name, current_source, plugin_prefix, plugin_settings, field_value):
+def can_overwrite_field(field_name, current_source, plugin_settings, field_value):
Option 2: Use for debug logging
     # Rule 1: USER and LOCKED are protected
     if current_source in ("USER", "LOCKED"):
+        mylog("trace", [f"[can_overwrite_field] {plugin_prefix} blocked from overwriting {field_name}: source is {current_source}"])
         return False

120-135: Unused field_name parameter.

The field_name parameter is declared but never used in get_source_for_field_update. If it's intended for future use or consistency with other function signatures, consider prefixing with underscore to indicate intentional non-use.

Suggested fix
-def get_source_for_field_update(field_name, plugin_prefix, is_user_override=False):
+def get_source_for_field_update(_field_name, plugin_prefix, is_user_override=False):

156-156: Unused loop variable.

The new_value variable is not used in the loop body. Rename to _new_value to indicate intentional non-use.

Suggested fix
-    for field_name, new_value in updates_dict.items():
+    for field_name, _new_value in updates_dict.items():

Comment on lines +300 to +305
"FieldLock_Error": "",
"FieldLock_Lock_Tooltip": "",
"FieldLock_Locked": "",
"FieldLock_Source_Label": "",
"FieldLock_Unlock_Tooltip": "",
"FieldLock_Unlocked": "",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and get context around lines 300-305
find . -name "sv_sv.json" -type f

Repository: netalertx/NetAlertX

Length of output: 104


🏁 Script executed:

# Also search for language file handling to understand the fallback mechanism
find . -name "*.php" -o -name "*.js" | head -20

Repository: netalertx/NetAlertX

Length of output: 568


🏁 Script executed:

# Search for how language files are loaded and handled
rg -t php "sv_sv|language.*fallback|translation.*fallback" --max-count=20

Repository: netalertx/NetAlertX

Length of output: 505


🏁 Script executed:

# Read sv_sv.json around lines 300-305 to verify the content
sed -n '298,307p' ./front/php/templates/language/sv_sv.json

Repository: netalertx/NetAlertX

Length of output: 365


🏁 Script executed:

# Find and check en_us.json for the same FieldLock_* keys
find . -name "en_us.json" -type f

Repository: netalertx/NetAlertX

Length of output: 104


🏁 Script executed:

# Read the fallback implementation in lang.php
rg -A 10 "If key not found in selected language" ./front/php/templates/language/lang.php

Repository: netalertx/NetAlertX

Length of output: 509


🏁 Script executed:

# Extract FieldLock_* entries from en_us.json
rg 'FieldLock_' ./front/php/templates/language/en_us.json -A 0

Repository: netalertx/NetAlertX

Length of output: 393


🏁 Script executed:

# Get the actual values for FieldLock_* keys in en_us.json
rg '"FieldLock_' ./front/php/templates/language/en_us.json

Repository: netalertx/NetAlertX

Length of output: 393


🏁 Script executed:

# Check how many untranslated (empty string) keys exist in sv_sv.json
rg '": "",' ./front/php/templates/language/sv_sv.json | wc -l

Repository: netalertx/NetAlertX

Length of output: 66


🏁 Script executed:

# Compare to en_us.json to assess translation completeness
rg '": "",' ./front/php/templates/language/en_us.json | wc -l

Repository: netalertx/NetAlertX

Length of output: 64


🏁 Script executed:

# Check file sizes to understand translation coverage
wc -l ./front/php/templates/language/sv_sv.json ./front/php/templates/language/en_us.json

Repository: netalertx/NetAlertX

Length of output: 170


Complete Swedish translations or remove untranslated keys.

The FieldLock_* UI labels are empty strings in the Swedish translation file. Because the fallback logic checks for key existence (not value content), empty strings will render as blank UI instead of falling back to English. Note that the Swedish translation file is incomplete overall—775 out of 777 keys are empty strings, suggesting this file needs either full translation or to be removed from the language list until translations are ready.

🤖 Prompt for AI Agents
In `@front/php/templates/language/sv_sv.json` around lines 300 - 305, The Swedish
translation file contains empty values for the FieldLock_* keys
(FieldLock_Error, FieldLock_Lock_Tooltip, FieldLock_Locked,
FieldLock_Source_Label, FieldLock_Unlock_Tooltip, FieldLock_Unlocked) which
results in blank UI because fallback only checks key existence; either provide
proper Swedish translations for those keys (replace empty strings with localized
text) or remove these keys from the sv_sv JSON (or remove sv_sv from the
available languages) so the app can fall back to English—update the keys in the
same JSON object where FieldLock_* are defined and ensure no empty string values
remain.

Comment on lines 85 to +87
},
{ "function": "SET_ALWAYS",
"type": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix missing commas between settings objects (invalid JSON).

The settings array lacks commas between consecutive objects, which makes the JSON invalid and will prevent the plugin config from loading.

🛠️ Proposed fix
-    }
+    },
@@
-    }
+    },
@@
-    }
+    },

Also applies to: 115-117, 146-148

🤖 Prompt for AI Agents
In `@front/plugins/dig_scan/config.json` around lines 85 - 87, The JSON settings
array is invalid because object entries are missing commas between them; add a
trailing comma after each settings object where you see entries like {
"function": "SET_ALWAYS", "type": ... } and the neighboring object literals
(also at the other occurrences around the sections referenced near the second
and third SET_ALWAYS blocks) so each object in the array is separated by a
comma, ensuring the arrays containing "function":"SET_ALWAYS" and adjacent
objects are properly comma-delimited.

Comment on lines +571 to +632
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"transformers": []
}
]
},
"default_value": ["devMac", "devLastIP"],
"options": [
"devMac",
"devLastIP"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set always columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set empty columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are only overwritten if they are empty or set to NEWDEV."
}
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SET_ALWAYS/SET_EMPTY are in the wrong section.

These blocks use the settings schema (function, type, default_value) but are placed inside database_column_definitions, which expects column entries. They will likely be ignored or cause validation errors. Move them into the settings array with the other RUN/CMD entries. As per coding guidelines, plugin settings should live in the manifest’s settings section and be read via get_setting_value().

🛠️ Proposed fix (remove from database_column_definitions)
-    {
-      "function": "SET_ALWAYS",
-      "type": {
-        "dataType": "array",
-        "elements": [
-          {
-            "elementType": "select",
-            "elementOptions": [{ "multiple": "true", "orderable": "true" }],
-            "transformers": []
-          }
-        ]
-      },
-      "default_value": ["devMac", "devLastIP"],
-      "options": [
-        "devMac",
-        "devLastIP"
-      ],
-      "localized": ["name", "description"],
-      "name": [
-        {
-          "language_code": "en_us",
-          "string": "Set always columns"
-        }
-      ],
-      "description": [
-        {
-          "language_code": "en_us",
-          "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
-        }
-      ]
-    },
-    {
-      "function": "SET_EMPTY",
-      "type": {
-        "dataType": "array",
-        "elements": [
-          {
-            "elementType": "select",
-            "elementOptions": [{ "multiple": "true", "orderable": "true" }],
-            "transformers": []
-          }
-        ]
-      },
-      "default_value": [],
-      "options": [
-        "devMac",
-        "devLastIP"
-      ],
-      "localized": ["name", "description"],
-      "name": [
-        {
-          "language_code": "en_us",
-          "string": "Set empty columns"
-        }
-      ],
-      "description": [
-        {
-          "language_code": "en_us",
-          "string": "These columns are only overwritten if they are empty or set to NEWDEV."
-        }
-      ]
-    }

You can re-add these blocks under settings (e.g., after RUN_TIMEOUT).

🤖 Prompt for AI Agents
In `@front/plugins/luci_import/config.json` around lines 571 - 632, The JSON
entries for "SET_ALWAYS" and "SET_EMPTY" are currently placed under
database_column_definitions but are settings schema entries and should be moved
into the manifest's settings array; locate the objects with "function":
"SET_ALWAYS" and "function": "SET_EMPTY" and remove them from
database_column_definitions, then re-add them into the settings array (for
example after "RUN_TIMEOUT") so the plugin reads them via get_setting_value();
ensure their schema (type, default_value, options, localized/name/description)
is preserved exactly when relocating.

Comment on lines +219 to +344
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devLastIP"],
"options": [
"devLastIP"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set always columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devLastIP"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set empty columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are only overwritten if they are empty or set to NEWDEV."
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devMac", "devLastIP"],
"options": [
"devMac",
"devLastIP",
"devName",
"devVendor"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set always columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devName", "devVendor"],
"options": [
"devMac",
"devLastIP",
"devName",
"devVendor"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set empty columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are only overwritten if they are empty or set to NEWDEV."
}
]
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Duplicate SET_ALWAYS/SET_EMPTY settings cause ambiguous behavior

You define SET_ALWAYS and SET_EMPTY twice with different defaults/options. This will likely render duplicate UI entries or create non-deterministic precedence when settings are read. Please keep only one authoritative block and consolidate the intended defaults/options.

🛠️ Suggested fix (remove the earlier duplicate pair)
-    {
-      "function": "SET_ALWAYS",
-      "type": {
-        "dataType": "array",
-        "elements": [
-          {
-            "elementType": "select",
-            "elementOptions": [{ "multiple": "true"}],
-            "transformers": []
-          }
-        ]
-      },
-      "default_value": ["devLastIP"],
-      "options": [
-        "devLastIP"
-      ],
-      "localized": ["name", "description"],
-      "name": [
-        {
-          "language_code": "en_us",
-          "string": "Set always columns"
-        }
-      ],
-      "description": [
-        {
-          "language_code": "en_us",
-          "string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
-        }
-      ]
-    },
-    {
-      "function": "SET_EMPTY",
-      "type": {
-        "dataType": "array",
-        "elements": [
-          {
-            "elementType": "select",
-            "elementOptions": [{ "multiple": "true"}],
-            "transformers": []
-          }
-        ]
-      },
-      "default_value": [],
-      "options": [
-        "devLastIP"
-      ],
-      "localized": ["name", "description"],
-      "name": [
-        {
-          "language_code": "en_us",
-          "string": "Set empty columns"
-        }
-      ],
-      "description": [
-        {
-          "language_code": "en_us",
-          "string": "These columns are only overwritten if they are empty or set to NEWDEV."
-        }
-      ]
-    },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devLastIP"],
"options": [
"devLastIP"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set always columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devLastIP"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set empty columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are only overwritten if they are empty or set to NEWDEV."
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devMac", "devLastIP"],
"options": [
"devMac",
"devLastIP",
"devName",
"devVendor"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set always columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devName", "devVendor"],
"options": [
"devMac",
"devLastIP",
"devName",
"devVendor"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set empty columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are only overwritten if they are empty or set to NEWDEV."
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devMac", "devLastIP"],
"options": [
"devMac",
"devLastIP",
"devName",
"devVendor"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set always columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"transformers": []
}
]
},
"default_value": ["devName", "devVendor"],
"options": [
"devMac",
"devLastIP",
"devName",
"devVendor"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set empty columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are only overwritten if they are empty or set to NEWDEV."
}
]
},
🤖 Prompt for AI Agents
In `@front/plugins/pihole_scan/config.json` around lines 219 - 344, There are
duplicate configuration blocks for the functions "SET_ALWAYS" and "SET_EMPTY"
which creates ambiguity; remove the first pair (the blocks whose default_value
is ["devLastIP"] and [] respectively) and keep a single authoritative
"SET_ALWAYS" block with default_value ["devMac","devLastIP"] and a single
"SET_EMPTY" block with default_value ["devName","devVendor"], ensuring each
retained block's options include ["devMac","devLastIP","devName","devVendor"]
and keep their localized name/description entries intact.

Comment on lines 42 to +44
"params": [],
}
],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix invalid JSON after params.

The stray } and ] make this file unparsable.

🛠️ Proposed fix
-  "params": [],
-    }
-  ],
+  "params": [],
🧰 Tools
🪛 Biome (2.1.2)

[error] 43-43: Expected a property but instead found '}'.

Expected a property here.

(parse)


[error] 44-44: End of file expected

Use an array for a sequence of values: [1, 2]

(parse)

🤖 Prompt for AI Agents
In `@front/plugins/unifi_api_import/config.json` around lines 42 - 44, The JSON is
invalid due to stray closing tokens after the "params" property; locate the
object that contains the "params" key in
front/plugins/unifi_api_import/config.json (the entry showing `"params": []`)
and remove the extra trailing `}` and `]` shown after that property so the
surrounding object/array syntax is correct (ensure no dangling commas remain and
the enclosing object/array are properly closed).

Comment on lines +499 to +501
except Exception as e:
mylog("error", f"Error locking field {field_name} for {mac}: {str(e)}")
return jsonify({"success": False, "error": str(e)}), 500
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use allowed log levels in mylog.

mylog("error", ...) violates the permitted levels and also skips the expected list payload.

🔧 Proposed fix
-    except Exception as e:
-        mylog("error", f"Error locking field {field_name} for {mac}: {str(e)}")
+    except Exception as e:
+        mylog("verbose", [f"Error locking field {field_name} for {mac}: {e!s}"])
         return jsonify({"success": False, "error": str(e)}), 500

As per coding guidelines, use only none/minimal/verbose/debug/trace with mylog().

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception as e:
mylog("error", f"Error locking field {field_name} for {mac}: {str(e)}")
return jsonify({"success": False, "error": str(e)}), 500
except Exception as e:
mylog("verbose", [f"Error locking field {field_name} for {mac}: {e!s}"])
return jsonify({"success": False, "error": str(e)}), 500
🧰 Tools
🪛 Ruff (0.14.13)

499-499: Do not catch blind exception: Exception

(BLE001)


500-500: Use explicit conversion flag

Replace with conversion flag

(RUF010)

🤖 Prompt for AI Agents
In `@server/api_server/api_server_start.py` around lines 499 - 501, Replace the
illegal mylog("error", ...) call in the exception handler with one of the
allowed levels (none/minimal/verbose/debug/trace) and pass the message as a list
payload per the logging contract; specifically update the catch block that
references field_name and mac to call e.g. mylog("minimal", [f"Error locking
field {field_name} for {mac}", str(e)]) and then return the same jsonify
response — ensure the first argument is one of the permitted level strings and
the second argument is a list containing the descriptive message and the error
detail.

Comment on lines +681 to +705
def lockDeviceField(self, mac, field_name):
"""Lock a device field so it won't be overwritten by plugins."""
if field_name not in FIELD_SOURCE_MAP:
return {"success": False, "error": f"Field {field_name} does not support locking"}

try:
conn = get_temp_db_connection()
lock_field(mac, field_name, conn)
conn.close()
return {"success": True, "message": f"Field {field_name} locked"}
except Exception as e:
return {"success": False, "error": str(e)}

def unlockDeviceField(self, mac, field_name):
"""Unlock a device field so plugins can overwrite it again."""
if field_name not in FIELD_SOURCE_MAP:
return {"success": False, "error": f"Field {field_name} does not support unlocking"}

try:
conn = get_temp_db_connection()
unlock_field(mac, field_name, conn)
conn.close()
return {"success": True, "message": f"Field {field_name} unlocked"}
except Exception as e:
return {"success": False, "error": str(e)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, verify that normalize_mac() exists in the expected location
find . -name "plugin_helper.py" -type f | head -20

Repository: netalertx/NetAlertX

Length of output: 95


🏁 Script executed:

# Search for normalize_mac function definition
rg -n "def normalize_mac" --type py

Repository: netalertx/NetAlertX

Length of output: 218


🏁 Script executed:

# Check the current implementation of lockDeviceField and unlockDeviceField
cat -n server/models/device_instance.py | sed -n '681,705p'

Repository: netalertx/NetAlertX

Length of output: 1356


🏁 Script executed:

# Find all callers of lockDeviceField and unlockDeviceField
rg -n "lockDeviceField\(|unlockDeviceField\(" -C 2 --type py

Repository: netalertx/NetAlertX

Length of output: 850


🏁 Script executed:

# Search for lock_field and unlock_field function implementations
rg -n "def lock_field|def unlock_field" --type py -A 5

Repository: netalertx/NetAlertX

Length of output: 861


🏁 Script executed:

# Check imports in device_instance.py
head -50 server/models/device_instance.py | grep -n "import\|from"

Repository: netalertx/NetAlertX

Length of output: 699


🏁 Script executed:

# Check full lock_field and unlock_field implementations
rg -n "def lock_field|def unlock_field" --type py -A 20

Repository: netalertx/NetAlertX

Length of output: 2903


🏁 Script executed:

# Search for all callers of these methods to see if they normalize
rg -n "\.lockDeviceField\(|\.unlockDeviceField\(" --type py -B 2 -A 2

Repository: netalertx/NetAlertX

Length of output: 45


🏁 Script executed:

# Check if normalize_mac is already imported or used elsewhere in device_instance.py
grep -n "normalize_mac" server/models/device_instance.py

Repository: netalertx/NetAlertX

Length of output: 256


Normalize MACs and always close DB connections on lock/unlock.

Both lockDeviceField and unlockDeviceField have two issues:

  1. Missing MAC normalization: The mac parameter is passed directly to lock_field() and unlock_field() without normalization, causing database queries to miss rows when callers supply mixed-case MACs. normalize_mac() is already imported in this file and used elsewhere (lines 504-505) for the same purpose.

  2. Connection leak on exception: If an exception occurs in the try block, conn.close() is never called because there's no finally block, leaking database connections.

Apply the proposed fix to normalize the MAC before the database call and ensure the connection is closed regardless of exceptions:

🔧 Proposed fix
    def lockDeviceField(self, mac, field_name):
        """Lock a device field so it won't be overwritten by plugins."""
        if field_name not in FIELD_SOURCE_MAP:
            return {"success": False, "error": f"Field {field_name} does not support locking"}

-        try:
-            conn = get_temp_db_connection()
-            lock_field(mac, field_name, conn)
-            conn.close()
-            return {"success": True, "message": f"Field {field_name} locked"}
-        except Exception as e:
-            return {"success": False, "error": str(e)}
+        normalized_mac = normalize_mac(mac)
+        conn = None
+        try:
+            conn = get_temp_db_connection()
+            lock_field(normalized_mac, field_name, conn)
+            return {"success": True, "message": f"Field {field_name} locked"}
+        except Exception as e:
+            return {"success": False, "error": str(e)}
+        finally:
+            if conn:
+                conn.close()

    def unlockDeviceField(self, mac, field_name):
        """Unlock a device field so plugins can overwrite it again."""
        if field_name not in FIELD_SOURCE_MAP:
            return {"success": False, "error": f"Field {field_name} does not support unlocking"}

-        try:
-            conn = get_temp_db_connection()
-            unlock_field(mac, field_name, conn)
-            conn.close()
-            return {"success": True, "message": f"Field {field_name} unlocked"}
-        except Exception as e:
-            return {"success": False, "error": str(e)}
+        normalized_mac = normalize_mac(mac)
+        conn = None
+        try:
+            conn = get_temp_db_connection()
+            unlock_field(normalized_mac, field_name, conn)
+            return {"success": True, "message": f"Field {field_name} unlocked"}
+        except Exception as e:
+            return {"success": False, "error": str(e)}
+        finally:
+            if conn:
+                conn.close()
🧰 Tools
🪛 Ruff (0.14.13)

690-690: Consider moving this statement to an else block

(TRY300)


691-691: Do not catch blind exception: Exception

(BLE001)


703-703: Consider moving this statement to an else block

(TRY300)


704-704: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@server/models/device_instance.py` around lines 681 - 705, Both
lockDeviceField and unlockDeviceField must normalize the mac and always close
the DB connection; update lockDeviceField and unlockDeviceField to call
normalize_mac(mac) before using it with lock_field/unlock_field (and still
validate field_name against FIELD_SOURCE_MAP), obtain conn via
get_temp_db_connection(), run lock_field(mac_normalized, field_name, conn) /
unlock_field(mac_normalized, field_name, conn) inside try, and move conn.close()
into a finally block so the connection is closed whether an exception occurs;
preserve the same success/error return shapes.

Comment on lines +544 to +548
# Derive primary IP family values
cur_IP = str(cur_IP).strip() if cur_IP else ""
cur_IP_normalized = check_IP_format(cur_IP) if ":" not in cur_IP else cur_IP
primary_ipv4 = cur_IP_normalized if cur_IP_normalized and ":" not in cur_IP_normalized else ""
primary_ipv6 = cur_IP_normalized if cur_IP_normalized and ":" in cur_IP_normalized else ""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's see the actual code at lines 544-548 in device_handling.py
sed -n '540,555p' server/scan/device_handling.py

Repository: netalertx/NetAlertX

Length of output: 825


🏁 Script executed:

# Locate the check_IP_format function to understand what it validates
rg -n "def check_IP_format" --type=py -A 20

Repository: netalertx/NetAlertX

Length of output: 1123


🏁 Script executed:

# Search for any IPv6 validation or ipaddress module usage
rg -n "ipv6|IPv6|ipaddress" --type=py server/

Repository: netalertx/NetAlertX

Length of output: 2496


🏁 Script executed:

# Check what validation functions exist in helper files
rg -n "def.*[Ii][Pp]" --type=py server/ | head -20

Repository: netalertx/NetAlertX

Length of output: 1739


🏁 Script executed:

# Let's look at the format_ip_long function which appears to use ipaddress validation
sed -n '675,690p' server/helper.py

Repository: netalertx/NetAlertX

Length of output: 550


🏁 Script executed:

# Check if format_ip_long is called in device_handling.py
rg -n "format_ip_long" server/scan/device_handling.py

Repository: netalertx/NetAlertX

Length of output: 45


🏁 Script executed:

# Let's see the complete context around the IPv6 validation in helper.py
sed -n '675,695p' server/helper.py

Repository: netalertx/NetAlertX

Length of output: 741


🏁 Script executed:

# Check what happens to primary_ipv6 after it's set - is it validated before database insert?
sed -n '544,580p' server/scan/device_handling.py

Repository: netalertx/NetAlertX

Length of output: 1813


Add IPv6 format validation before database insert.

The code at lines 544-548 validates IPv4 addresses using check_IP_format() but passes IPv6 addresses through without validation. The format_ip_long() function in server/helper.py already provides IPv6 validation using the ipaddress module; consider applying similar validation to IPv6 addresses before inserting into the database to ensure data integrity and consistency with IPv4 handling.

🤖 Prompt for AI Agents
In `@server/scan/device_handling.py` around lines 544 - 548, The IPv6 branch
currently bypasses validation (cur_IP is passed through when ":" in cur_IP), so
add IPv6 validation consistent with IPv4 handling by reusing or mirroring the
logic in format_ip_long from server/helper.py: when cur_IP contains ":", call
the IPv6 validator (e.g., format_ip_long or a new helper wrapper) to
normalize/validate the address and only assign primary_ipv6 if the validator
returns a valid normalized value; keep primary_ipv4 assignment unchanged and
ensure invalid IPv6 values result in an empty primary_ipv6 string (or the same
error/empty behavior as invalid IPv4 via check_IP_format).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@test/authoritative_fields/test_device_field_lock.py`:
- Around line 272-290: The test test_locked_field_blocks_plugin_overwrite only
sets devNameSource to "LOCKED" and re-reads it but never attempts a
plugin/authoritative overwrite; update the test to simulate an overwrite (call
the authoritative update path or mimic a plugin write) after
device_handler.updateDeviceColumn(test_mac, "devNameSource", "LOCKED")—for
example use the authoritative handler or a DeviceInstance method that applies a
source="PLUGIN" update (or call device_handler.setDeviceData with a plugin-like
payload) and then assert device_handler.getDeviceData(test_mac)["devNameSource"]
remains "LOCKED"; also remove the unused auth_headers parameter from the test
signature if you don’t need it or use it in the overwrite call.
- Around line 9-10: Remove the hardcoded '/app' default for NETALERTX_APP and
make the code fail fast when the environment variable is missing: read
os.getenv('NETALERTX_APP') into INSTALL_PATH, check if INSTALL_PATH is falsy and
raise a clear exception (or call sys.exit with an error) rather than falling
back to '/app', then use INSTALL_PATH in the existing sys.path.extend([...])
call so imports cannot be silently misconfigured.
- Around line 178-204: The test_lock_prevents_field_updates currently only
asserts response codes; after calling the update endpoint with update_payload,
add a follow-up GET (e.g., call the same client to GET /device/{test_mac} or use
existing helper test_create_test_device/get_device helper) to retrieve the
device record and assert that devName remains the original value created by
test_create_test_device; reference the test function name
test_lock_prevents_field_updates, the lock endpoint used with lock_payload, and
the update POST using update_payload so you place the assertion right after the
update response check.
- Around line 42-58: The fixture cleanup_test_device currently swallows all
exceptions during pre/post cleanup which hides failures; update it to stop
catching exceptions silently—either let exceptions from
DeviceInstance.deleteDeviceByMAC propagate or explicitly fail the test using
pytest.fail with the caught exception message; modify the try/except blocks
around deleteDeviceByMAC in cleanup_test_device to remove the bare excepts (or
replace them with except Exception as e: pytest.fail(f"...{e}")) so cleanup
failures surface, and keep references to DeviceInstance and deleteDeviceByMAC to
locate the calls.

In `@test/authoritative_fields/test_field_lock_scan_integration.py`:
- Around line 1-12: The test module test_field_lock_scan_integration.py is too
large; split it into smaller modules by scenario groups (e.g. name_vendor
updates, ip_updates, presence_mixed_cases) so each file stays under 500 LOC;
move related test functions and any helper fixtures/classes into the new files
(e.g. test_field_lock_scan_integration_name_vendor.py,
test_field_lock_scan_integration_ip.py,
test_field_lock_scan_integration_presence.py), update any relative imports or
conftest references to keep fixtures (e.g. fixtures used by tests in this file)
available, and ensure pytest discovery still finds tests by keeping top-level
test_*.py filenames and preserving the original test function and class names
(so functions referenced by device_handling scan update integration tests remain
unchanged).
🧹 Nitpick comments (3)
test/authoritative_fields/test_device_field_lock.py (2)

12-14: Drop unused # noqa: E402 directives.

Ruff reports these as unused; removing them keeps lint clean.

♻️ Proposed cleanup
-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 helper import get_setting_value
+from api_server.api_server_start import app
+from models.device_instance import DeviceInstance

292-316: Remove unused auth_headers parameter.

Ruff flags the argument as unused; dropping it (or prefixing with _) will keep lint clean.

test/authoritative_fields/test_field_lock_scan_integration.py (1)

110-183: Avoid unused fixture parameters; use @pytest.mark.usefixtures.

The fixture is used for side effects only, so passing it as an unused argument triggers ARG001. This applies to all tests in this module.

♻️ Proposed pattern
+@pytest.mark.usefixtures("mock_device_handlers")
-def test_scan_updates_newdev_device_name(scan_db, mock_device_handlers):
+def test_scan_updates_newdev_device_name(scan_db):

Comment on lines +9 to +10
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid hardcoded default for NETALERTX_APP.

The /app fallback can silently misconfigure imports when the env var isn’t set. Prefer requiring the environment variable explicitly. As per coding guidelines, avoid hardcoded paths.

🛠️ Proposed fix
-INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
+INSTALL_PATH = os.getenv("NETALERTX_APP")
+if not INSTALL_PATH:
+    raise RuntimeError("NETALERTX_APP must be set for tests")
🤖 Prompt for AI Agents
In `@test/authoritative_fields/test_device_field_lock.py` around lines 9 - 10,
Remove the hardcoded '/app' default for NETALERTX_APP and make the code fail
fast when the environment variable is missing: read os.getenv('NETALERTX_APP')
into INSTALL_PATH, check if INSTALL_PATH is falsy and raise a clear exception
(or call sys.exit with an error) rather than falling back to '/app', then use
INSTALL_PATH in the existing sys.path.extend([...]) call so imports cannot be
silently misconfigured.

Comment on lines +42 to +58
@pytest.fixture(autouse=True)
def cleanup_test_device(test_mac):
"""Clean up test device before and after test."""
device_handler = DeviceInstance()
# Clean before test
try:
device_handler.deleteDeviceByMAC(test_mac)
except Exception:
pass

yield

# Clean after test
try:
device_handler.deleteDeviceByMAC(test_mac)
except Exception:
pass
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t silently ignore cleanup failures.

Swallowing exceptions can hide DB/setup issues and make tests flaky. Consider letting failures surface.

🛠️ Proposed fix
-    try:
-        device_handler.deleteDeviceByMAC(test_mac)
-    except Exception:
-        pass
+    device_handler.deleteDeviceByMAC(test_mac)
@@
-    try:
-        device_handler.deleteDeviceByMAC(test_mac)
-    except Exception:
-        pass
+    device_handler.deleteDeviceByMAC(test_mac)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.fixture(autouse=True)
def cleanup_test_device(test_mac):
"""Clean up test device before and after test."""
device_handler = DeviceInstance()
# Clean before test
try:
device_handler.deleteDeviceByMAC(test_mac)
except Exception:
pass
yield
# Clean after test
try:
device_handler.deleteDeviceByMAC(test_mac)
except Exception:
pass
`@pytest.fixture`(autouse=True)
def cleanup_test_device(test_mac):
"""Clean up test device before and after test."""
device_handler = DeviceInstance()
# Clean before test
device_handler.deleteDeviceByMAC(test_mac)
yield
# Clean after test
device_handler.deleteDeviceByMAC(test_mac)
🧰 Tools
🪛 Ruff (0.14.13)

49-50: try-except-pass detected, consider logging the exception

(S110)


49-49: Do not catch blind exception: Exception

(BLE001)


57-58: try-except-pass detected, consider logging the exception

(S110)


57-57: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@test/authoritative_fields/test_device_field_lock.py` around lines 42 - 58,
The fixture cleanup_test_device currently swallows all exceptions during
pre/post cleanup which hides failures; update it to stop catching exceptions
silently—either let exceptions from DeviceInstance.deleteDeviceByMAC propagate
or explicitly fail the test using pytest.fail with the caught exception message;
modify the try/except blocks around deleteDeviceByMAC in cleanup_test_device to
remove the bare excepts (or replace them with except Exception as e:
pytest.fail(f"...{e}")) so cleanup failures surface, and keep references to
DeviceInstance and deleteDeviceByMAC to locate the calls.

Comment on lines +178 to +204
def test_lock_prevents_field_updates(self, client, test_mac, auth_headers):
"""Locked field should not be updated through API."""
# Create device with initial name
self.test_create_test_device(client, test_mac, auth_headers)

# Lock the field
lock_payload = {"fieldName": "devName", "lock": True}
resp = client.post(
f"/device/{test_mac}/field/lock",
json=lock_payload,
headers=auth_headers
)
assert resp.status_code == 200

# Try to update the locked field
update_payload = {"devName": "New Name"}
resp = client.post(
f"/device/{test_mac}",
json=update_payload,
headers=auth_headers
)

# Update should succeed at API level but authoritative handler should prevent it
# The field update logic checks source in the database layer
# For now verify the API accepts the request
assert resp.status_code in [200, 201]

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add an assertion that the locked field stays unchanged.

This test currently only checks the response code, so it doesn’t validate the locking behavior it describes.

🛠️ Proposed assertion
     assert resp.status_code in [200, 201]
+
+    # Verify locked field remains unchanged
+    resp = client.get(f"/device/{test_mac}", headers=auth_headers)
+    assert resp.status_code == 200
+    device_data = resp.json
+    assert device_data.get("devName") == "Test Device"
+    assert device_data.get("devNameSource") == "LOCKED"
🤖 Prompt for AI Agents
In `@test/authoritative_fields/test_device_field_lock.py` around lines 178 - 204,
The test_lock_prevents_field_updates currently only asserts response codes;
after calling the update endpoint with update_payload, add a follow-up GET
(e.g., call the same client to GET /device/{test_mac} or use existing helper
test_create_test_device/get_device helper) to retrieve the device record and
assert that devName remains the original value created by
test_create_test_device; reference the test function name
test_lock_prevents_field_updates, the lock endpoint used with lock_payload, and
the update POST using update_payload so you place the assertion right after the
update response check.

Comment on lines +272 to +290
def test_locked_field_blocks_plugin_overwrite(self, test_mac, auth_headers):
"""Verify locked fields prevent plugin source overwrites."""
device_handler = DeviceInstance()

# Create device
create_result = device_handler.setDeviceData(test_mac, {
"devName": "Original Name",
"devLastIP": "192.168.1.100",
"createNew": True
})
assert create_result.get("success") is True

# Lock the field
device_handler.updateDeviceColumn(test_mac, "devNameSource", "LOCKED")

# Try to overwrite with plugin source (this would be done by authoritative handler)
# For now, verify the source is stored correctly
device_data = device_handler.getDeviceData(test_mac)
assert device_data.get("devNameSource") == "LOCKED"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test doesn’t actually validate overwrite blocking.

It only sets the source and re-reads it; consider triggering an authoritative overwrite attempt (e.g., via authoritative handler or scan update) and asserting the value stays unchanged. Also, auth_headers is unused here.

🧰 Tools
🪛 Ruff (0.14.13)

272-272: Unused method argument: auth_headers

(ARG002)

🤖 Prompt for AI Agents
In `@test/authoritative_fields/test_device_field_lock.py` around lines 272 - 290,
The test test_locked_field_blocks_plugin_overwrite only sets devNameSource to
"LOCKED" and re-reads it but never attempts a plugin/authoritative overwrite;
update the test to simulate an overwrite (call the authoritative update path or
mimic a plugin write) after device_handler.updateDeviceColumn(test_mac,
"devNameSource", "LOCKED")—for example use the authoritative handler or a
DeviceInstance method that applies a source="PLUGIN" update (or call
device_handler.setDeviceData with a plugin-like payload) and then assert
device_handler.getDeviceData(test_mac)["devNameSource"] remains "LOCKED"; also
remove the unused auth_headers parameter from the test signature if you don’t
need it or use it in the overwrite call.

Comment on lines +1 to +12
"""
Integration tests for device field locking during actual scan updates.
Simulates real-world scenarios by:
1. Setting up Devices table with various source values
2. Populating CurrentScan with new discovery data
3. Running actual device_handling scan updates
4. Verifying field updates respect authorization rules
Tests all combinations of field sources (LOCKED, USER, NEWDEV, plugin name)
with realistic scan data.
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Split this test module to stay under 500 LOC.

This file exceeds the 500-line guideline; consider splitting by scenario group (name/vendor updates, IP updates, presence/mixed cases). As per coding guidelines, keep files <=500 lines.

🤖 Prompt for AI Agents
In `@test/authoritative_fields/test_field_lock_scan_integration.py` around lines 1
- 12, The test module test_field_lock_scan_integration.py is too large; split it
into smaller modules by scenario groups (e.g. name_vendor updates, ip_updates,
presence_mixed_cases) so each file stays under 500 LOC; move related test
functions and any helper fixtures/classes into the new files (e.g.
test_field_lock_scan_integration_name_vendor.py,
test_field_lock_scan_integration_ip.py,
test_field_lock_scan_integration_presence.py), update any relative imports or
conftest references to keep fixtures (e.g. fixtures used by tests in this file)
available, and ensure pytest discovery still finds tests by keeping top-level
test_*.py filenames and preserving the original test function and class names
(so functions referenced by device_handling scan update integration tests remain
unchanged).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants