From 0b47afac9cde617e4f90a3d118d7d7c94862d891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Hinz?= Date: Thu, 30 Apr 2026 16:00:00 +0200 Subject: [PATCH 01/40] custom hostname and displayname for zabbix --- nbxsync/__init__.py | 2 ++ nbxsync/settings.py | 4 ++++ nbxsync/utils/sync/hostsync.py | 25 +++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/nbxsync/__init__.py b/nbxsync/__init__.py index 7311d8d4..7d1fc860 100644 --- a/nbxsync/__init__.py +++ b/nbxsync/__init__.py @@ -117,6 +117,8 @@ class nbxSync(PluginConfig): 'attach_objtag': False, 'objtag_type': 'nb_type', 'objtag_id': 'nb_id', + 'custom_field_hostname': '', + 'custom_field_display_name': '', } queues = [] validated_config = None diff --git a/nbxsync/settings.py b/nbxsync/settings.py index 7e657b69..ebcd0c2f 100644 --- a/nbxsync/settings.py +++ b/nbxsync/settings.py @@ -112,6 +112,10 @@ class PluginSettingsModel(BaseModel): objtag_id: str = Field(default='nb_id') + custom_field_hostname: str = Field(default='') + custom_field_display_name: str = Field(default='') + + # Helper function def get_plugin_settings() -> PluginSettingsModel: plugin_config = apps.get_app_config('nbxsync') diff --git a/nbxsync/utils/sync/hostsync.py b/nbxsync/utils/sync/hostsync.py index 1882144b..23d06883 100644 --- a/nbxsync/utils/sync/hostsync.py +++ b/nbxsync/utils/sync/hostsync.py @@ -20,13 +20,34 @@ class HostSync(ZabbixSyncBase): def api_object(self): return self.api.host - def get_name_value(self): + def get_base_name(self): # If the object has the "name" attribute, only return that (Device). If not (cornercase?), return the display string if hasattr(self.obj.assigned_object, 'name'): return self.obj.assigned_object.name return str(self.obj.assigned_object) + def get_name_value(self): + base_name = self.get_base_name() + cf_name = getattr(self.pluginsettings, 'custom_field_hostname', '') + if cf_name and hasattr(self.obj.assigned_object, 'custom_field_data'): + cf_value = self.obj.assigned_object.custom_field_data.get(cf_name) + if cf_value: + return str(cf_value) + return base_name + + def get_display_name(self): + base_name = self.get_base_name() + cf_name = getattr(self.pluginsettings, 'custom_field_display_name', '') + if cf_name and hasattr(self.obj.assigned_object, 'custom_field_data'): + cf_value = self.obj.assigned_object.custom_field_data.get(cf_name) + if cf_value: + return str(cf_value) + return base_name + + def find_by_name(self): + return self.api_object().get(filter={'host': self.sanitize_string(input_str=str(self.get_name_value()))}) + def get_create_params(self): status = self.obj.assigned_object.status object_type = self.obj.assigned_object._meta.model_name # "device" or "virtualmachine" @@ -41,7 +62,7 @@ def get_create_params(self): return { 'host': self.sanitize_string(input_str=str(self.get_name_value())), - 'name': self.get_name_value(), + 'name': self.get_display_name(), 'groups': self.get_groups(), 'status': host_status, 'description': self.obj.assigned_object.description or '', From 5208a1cd358289e6837fae9f07a54c3ce3be6ee8 Mon Sep 17 00:00:00 2001 From: bhinz Date: Thu, 30 Apr 2026 16:35:03 +0200 Subject: [PATCH 02/40] Update installation.md added configuration options --- docs/installation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/installation.md b/docs/installation.md index 1b319382..9bd258f2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -110,6 +110,8 @@ PLUGINS_CONFIG = { 'attach_objtag': True, 'objtag_type': 'nb_type', 'objtag_id': 'nb_id', + 'custom_field_hostname':'', + 'custom_field_display_name':'', } } ``` From f25e15dbea82c07d4df9b1d730015689cfd8d32e Mon Sep 17 00:00:00 2001 From: bhinz Date: Thu, 30 Apr 2026 16:40:36 +0200 Subject: [PATCH 03/40] custom_fields added --- docs/configuration.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2316c26e..8dc66c9c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,7 +78,9 @@ The plugin is configuration to do exactly what you want, by means of the plugin 'no_alerting_tag_value': '1', 'attach_objtag': True, 'objtag_type': 'nb_type', - 'objtag_id': 'nb_id' + 'objtag_id': 'nb_id', + 'custom_field_hostname':'', + 'custom_field_display_name':'' } ``` @@ -208,6 +210,9 @@ These tags allow you to navigate from a Zabbix host back to the corresponding N | `objtag_type` | `nb_type` | Tag name for the NetBox object type | | `objtag_id` | `nb_id` | Tag name for the NetBox object ID | +### custom_field_hostname and custom_field_display_name +You can use these fields to map the connection between NetBox and the Zabbix hostname and display name. The device name is used as the default. + ## Enabling and Disabling Synchronization Two separate `sync_enabled` flags control whether synchronization to Zabbix is active. @@ -216,4 +221,4 @@ Two separate `sync_enabled` flags control whether synchronization to Zabbix is a **On `ZabbixServerAssignment`**: disabling this stops synchronization for that specific device/VM assignment only. Other assignments to the same or different Zabbix servers are unaffected. -Both flags must be `True` for a sync to proceed. The "Sync Status" column on the Zabbix Server Assignments list shows a green check only when both the assignment and its server are enabled. \ No newline at end of file +Both flags must be `True` for a sync to proceed. The "Sync Status" column on the Zabbix Server Assignments list shows a green check only when both the assignment and its server are enabled. From d5dcb58d8a81645e2126d614e22c4e5739ea2516 Mon Sep 17 00:00:00 2001 From: QWERin Date: Mon, 1 Jun 2026 11:48:26 +0200 Subject: [PATCH 04/40] Update pydantic dependency version constraints fix for netbox 4.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4984a8bc..b9b28f4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ keywords = ["netbox", "netbox-plugin", "zabbix", "nbxsync"] dependencies = [ "zabbix_utils", - "pydantic<2.11", + "pydantic>=2.7,<3", ] [project.optional-dependencies] From 29c9ec7621c69a1b5e28f46f54f36263c0b14f21 Mon Sep 17 00:00:00 2001 From: ahenobarbus Date: Wed, 24 Jun 2026 13:52:28 +0200 Subject: [PATCH 05/40] Add optional trigger dependency synchronization --- .gitignore | 5 + docs/configuration.md | 83 +++++- nbxsync/__init__.py | 33 +++ nbxsync/jobs/synchost.py | 4 + nbxsync/settings.py | 65 ++++- nbxsync/tests/jobs/test_synchost.py | 23 +- nbxsync/tests/test_plugin_settings.py | 26 +- .../utils/test_trigger_dependency_sync.py | 78 ++++++ nbxsync/utils/trigger_dependency_sync.py | 255 ++++++++++++++++++ 9 files changed, 557 insertions(+), 15 deletions(-) create mode 100644 .gitignore create mode 100644 nbxsync/tests/utils/test_trigger_dependency_sync.py create mode 100644 nbxsync/utils/trigger_dependency_sync.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0650fbc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ diff --git a/docs/configuration.md b/docs/configuration.md index 2316c26e..8e0293fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -The plugin is configuration to do exactly what you want, by means of the plugin settings. As described in the [installation instructions](installation.md)), the default configuration is as follows: +The plugin can be configured with NetBox plugin settings. As described in the [installation instructions](installation.md), the default configuration is as follows: ```python "nbxsync": { @@ -74,6 +74,39 @@ The plugin is configuration to do exactly what you want, by means of the plugin 'interval': 15, # 15 minutes }, }, + 'trigger_dependencies': { + 'enabled': False, + 'levels': [ + { + 'name': 'access_point', + 'roles': ['access point', 'access-point', 'ap'], + 'trigger_description': 'AP status', + }, + { + 'name': 'switch', + 'roles': ['switch', 'switches'], + 'trigger_description': 'Switch status', + }, + { + 'name': 'gateway', + 'roles': [ + 'gateway', + 'gateways', + 'gw', + 'firewall', + 'firewalls', + 'meraki gw', + 'meraki-gw', + 'meraki mx', + 'meraki-mx', + 'mx', + 'security appliance', + 'security-appliance', + ], + 'trigger_description': 'Gateway status', + }, + ], + }, 'no_alerting_tag': 'NO_ALERTING', 'no_alerting_tag_value': '1', 'attach_objtag': True, @@ -86,11 +119,11 @@ The plugin is configuration to do exactly what you want, by means of the plugin ### Source of Truth -The `sot` key determines which system is the Source of Truth: `netbox` or `zabbix`. And as such, which way the sync works. If the SoT is Netbox, data will be synschronized from Netbox to Zabbix. If the SoT is Zabbix, data is synchronized from Zabbix to Netbox - if and where possible (Zabbix doesn't expose all information). +The `sot` key determines which system is the source of truth: `netbox` or `zabbix`. This controls the sync direction. If the SoT is NetBox, data is synchronized from NetBox to Zabbix. If the SoT is Zabbix, data is synchronized from Zabbix to NetBox where possible; Zabbix does not expose all information. ### Statusmapping -The `statusmapping` key influences how certain statusses are interpreted and used. The two models that are to be synchronized are Devices and Virtual Machines. Each of these can be configurated independently of eachother for maximum flexibility. +The `statusmapping` key controls how certain statuses are interpreted and used. Devices and Virtual Machines can be configured independently. The key is the `netbox` status whilst the value is the action to be taken in Zabbix. @@ -98,15 +131,15 @@ The key is the `netbox` status whilst the value is the action to be taken in Zab #### enabled -This status results in the host to be enabled in Zabbix +This status enables the host in Zabbix. #### disabled -This status results in the host to be disabled in Zabbix +This status disables the host in Zabbix. #### deleted -This status results in the host to be deleted from Zabbix +This status deletes the host from Zabbix. #### enabled_in_maintenance @@ -118,11 +151,11 @@ If a host has this status, it is enabled in Zabbix, but it will have a tag with ### backgroundsync -If wanted, system jobs can be used to automatically sync objects +System jobs can be used to automatically sync objects. #### objects -This key is used to determine if 'objects' (that is: Devices and/or Virtual Machines) are to be automatically synched to/from Zabbix +This key controls whether objects, such as Devices and Virtual Machines, are automatically synchronized to or from Zabbix. ##### enabled @@ -165,6 +198,38 @@ This runs as the `Zabbix Sync Maintenance job` system job. Only maintenance windows that have at least one period and one object assignment are included. See [Zabbix Maintenance](zabbixmaintenance.md) for details. +### trigger_dependencies + +When enabled, nbxSync updates Zabbix trigger dependencies after a successful device host sync. The feature uses NetBox cabling and ordered dependency levels to build dependency chains such as: + +```text +access point trigger -> switch trigger -> gateway/firewall trigger +``` + +This feature is disabled by default. Enable it only when the configured role tokens match your NetBox device role names or slugs and the configured trigger descriptions match your Zabbix trigger names. + +Both pieces are required: + +- Role matching decides which dependency level a NetBox device belongs to. Role names and slugs are compared case-insensitively. +- Interface cabling decides which directly connected devices are considered. +- The order of `levels` decides dependency direction. Lower levels depend on directly connected higher levels. + +For example, an access point role named `Wireless AP` will not match the defaults unless you add `wireless ap` or the role slug to the access point level's `roles`. A device with a matching role but no connected higher-level neighbor is skipped because nbxSync cannot determine its upstream dependency. + +| Key | Default | Description | +|------------|---------|-------------| +| `enabled` | `False` | Enable trigger dependency updates after host sync | +| `levels` | AP, switch, gateway/firewall | Ordered from lowest child to highest parent | +| `name` | Level-specific | Human-readable level name used for configuration clarity | +| `roles` | Level-specific role tokens | NetBox device role names/slugs that belong to this level | +| `trigger_description` | Level-specific trigger name | Zabbix trigger description on hosts in this level | + +The default levels are ordered as access point, switch, gateway/firewall. With those defaults, a connected access point depends on its connected switch, and a connected switch depends on its connected gateway or firewall. Cable direction does not matter; nbxSync looks at directly connected devices and uses the level order to decide which device is the child and which device is the parent. + +To support more device types, add their NetBox role names or slugs to the appropriate level. To support a different hierarchy, add or reorder levels from lowest child to highest parent. + +Existing Zabbix dependencies whose descriptions do not match the managed parent trigger descriptions are preserved. Dependencies matching the managed parent trigger descriptions are replaced with the current cabling-derived parent triggers. + ### no_alerting_tag This defines the tag to be set when a host has the 'enabled_no_alerting' status. Use just a string value with no ${ } around the tag. Defaults to 'NO_ALERTING'. @@ -216,4 +281,4 @@ Two separate `sync_enabled` flags control whether synchronization to Zabbix is a **On `ZabbixServerAssignment`**: disabling this stops synchronization for that specific device/VM assignment only. Other assignments to the same or different Zabbix servers are unaffected. -Both flags must be `True` for a sync to proceed. The "Sync Status" column on the Zabbix Server Assignments list shows a green check only when both the assignment and its server are enabled. \ No newline at end of file +Both flags must be `True` for a sync to proceed. The "Sync Status" column on the Zabbix Server Assignments list shows a green check only when both the assignment and its server are enabled. diff --git a/nbxsync/__init__.py b/nbxsync/__init__.py index 7311d8d4..b30d596e 100644 --- a/nbxsync/__init__.py +++ b/nbxsync/__init__.py @@ -111,6 +111,39 @@ class nbxSync(PluginConfig): 'interval': 15, # 15 minutes }, }, + 'trigger_dependencies': { + 'enabled': False, + 'levels': [ + { + 'name': 'access_point', + 'roles': ['access point', 'access-point', 'ap'], + 'trigger_description': 'AP status', + }, + { + 'name': 'switch', + 'roles': ['switch', 'switches'], + 'trigger_description': 'Switch status', + }, + { + 'name': 'gateway', + 'roles': [ + 'gateway', + 'gateways', + 'gw', + 'firewall', + 'firewalls', + 'meraki gw', + 'meraki-gw', + 'meraki mx', + 'meraki-mx', + 'mx', + 'security appliance', + 'security-appliance', + ], + 'trigger_description': 'Gateway status', + }, + ], + }, 'no_alerting_tag': 'NO_ALERTING', 'no_alerting_tag_value': '1', 'maintenance_window_duration': 3600, diff --git a/nbxsync/jobs/synchost.py b/nbxsync/jobs/synchost.py index 408fea0c..b78e4c5f 100644 --- a/nbxsync/jobs/synchost.py +++ b/nbxsync/jobs/synchost.py @@ -7,6 +7,7 @@ from nbxsync.utils.sync import HostGroupSync, HostInterfaceSync, HostSync, ProxyGroupSync, ProxySync, run_zabbix_operation from nbxsync.utils.sync.safe_delete import safe_delete from nbxsync.utils.sync.safe_sync import safe_sync +from nbxsync.utils.trigger_dependency_sync import sync_device_trigger_dependencies __all__ = ('SyncHostJob',) @@ -37,6 +38,9 @@ def run(self): self.sync_host(assignment) self.verify_hostinterfaces(assignment) + if object_type == 'device' and zabbix_status != ZabbixHostStatus.DELETED and pluginsettings.trigger_dependencies.enabled: + sync_device_trigger_dependencies(self.instance) + def delete_host(self, assignment): safe_delete(HostSync, assignment) diff --git a/nbxsync/settings.py b/nbxsync/settings.py index 7e657b69..016d532e 100644 --- a/nbxsync/settings.py +++ b/nbxsync/settings.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Tuple from django.apps import apps from pydantic import BaseModel, Field, field_validator @@ -6,7 +6,7 @@ from nbxsync.choices.syncsot import SyncSOT from nbxsync.choices.zabbixstatus import ZabbixHostStatus -__all__ = ('PluginSettingsModel',) +__all__ = ('PluginSettingsModel', 'TriggerDependencyConfig', 'TriggerDependencyLevelConfig') class SoTConfig(BaseModel): @@ -50,11 +50,72 @@ class BackgroundSync(BaseModel): maintenance: BackgroundSyncConfig = Field(default_factory=BackgroundSyncConfig) +class TriggerDependencyLevelConfig(BaseModel): + name: str + roles: List[str] + trigger_description: str + + @field_validator('name', 'trigger_description', mode='before') + def validate_non_empty_string(cls, v: str) -> str: + if not isinstance(v, str) or not v.strip(): + raise ValueError('Value must be a non-empty string') + return v.strip() + + @field_validator('roles', mode='before') + def validate_role_tokens(cls, v: List[str]) -> List[str]: + if not isinstance(v, list) or not v: + raise ValueError('Role tokens must be a non-empty list') + return v + + +class TriggerDependencyConfig(BaseModel): + enabled: bool = Field(default=False) + levels: List[TriggerDependencyLevelConfig] = Field( + default_factory=lambda: [ + TriggerDependencyLevelConfig( + name='access_point', + roles=['access point', 'access-point', 'ap'], + trigger_description='AP status', + ), + TriggerDependencyLevelConfig( + name='switch', + roles=['switch', 'switches'], + trigger_description='Switch status', + ), + TriggerDependencyLevelConfig( + name='gateway', + roles=[ + 'gateway', + 'gateways', + 'gw', + 'firewall', + 'firewalls', + 'meraki gw', + 'meraki-gw', + 'meraki mx', + 'meraki-mx', + 'mx', + 'security appliance', + 'security-appliance', + ], + trigger_description='Gateway status', + ), + ] + ) + + @field_validator('levels', mode='before') + def validate_levels(cls, v: List[TriggerDependencyLevelConfig]) -> List[TriggerDependencyLevelConfig]: + if not isinstance(v, list) or not v: + raise ValueError('Dependency levels must be a non-empty list') + return v + + class PluginSettingsModel(BaseModel): sot: SoTConfig = SoTConfig() statusmapping: StatusMapping = Field(default_factory=StatusMapping) snmpconfig: SNMPConfig = Field(default_factory=SNMPConfig) backgroundsync: BackgroundSync = Field(default_factory=BackgroundSync) + trigger_dependencies: TriggerDependencyConfig = Field(default_factory=TriggerDependencyConfig) inheritance_chain: List[Tuple[str, ...]] = Field( default_factory=lambda: [ ('device',), diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index 9ed526dc..d570a413 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -11,11 +11,14 @@ from nbxsync.choices.zabbixstatus import ZabbixHostStatus from nbxsync.jobs.synchost import SyncHostJob from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixProxy, ZabbixProxyGroup, ZabbixServer, ZabbixServerAssignment -from nbxsync.utils.sync import HostSync, ProxyGroupSync, HostInterfaceSync +from nbxsync.settings import get_plugin_settings +from nbxsync.utils.sync import HostInterfaceSync, HostSync, ProxyGroupSync class SyncHostJobTestCase(TestCase): def setUp(self): + get_plugin_settings().trigger_dependencies.enabled = False + self.device = create_test_device(name='SyncHostVM') self.device_ct = ContentType.objects.get_for_model(Device) @@ -100,12 +103,26 @@ def test_run_sync_host_success(self): job = SyncHostJob(instance=self.device) job.run() + @patch('nbxsync.jobs.synchost.sync_device_trigger_dependencies') + def test_run_skips_trigger_dependency_sync_by_default(self, mock_sync_dependencies): + job = SyncHostJob(instance=self.device) + job.run() + + mock_sync_dependencies.assert_not_called() + + @patch('nbxsync.jobs.synchost.sync_device_trigger_dependencies') + def test_run_syncs_trigger_dependencies_when_enabled(self, mock_sync_dependencies): + get_plugin_settings().trigger_dependencies.enabled = True + + job = SyncHostJob(instance=self.device) + job.run() + + mock_sync_dependencies.assert_called_once_with(self.device) + def test_run_sync_host_deleted(self): self.device.status = 'decommissioning' self.device.save() # Set mapping to deleted for test - from nbxsync.settings import get_plugin_settings - pluginsettings = get_plugin_settings() pluginsettings.statusmapping.device['decommissioning'] = ZabbixHostStatus.DELETED diff --git a/nbxsync/tests/test_plugin_settings.py b/nbxsync/tests/test_plugin_settings.py index cf7b5098..ac6b8788 100644 --- a/nbxsync/tests/test_plugin_settings.py +++ b/nbxsync/tests/test_plugin_settings.py @@ -4,7 +4,14 @@ from pydantic import ValidationError from nbxsync.choices.syncsot import SyncSOT -from nbxsync.settings import PluginSettingsModel, SNMPConfig, BackgroundSyncConfig, get_plugin_settings +from nbxsync.settings import ( + PluginSettingsModel, + SNMPConfig, + BackgroundSyncConfig, + TriggerDependencyConfig, + TriggerDependencyLevelConfig, + get_plugin_settings, +) class PluginSettingsModelTestCase(TestCase): @@ -19,6 +26,8 @@ def test_default_settings_model(self): self.assertIsInstance(settings.backgroundsync.templates, BackgroundSyncConfig) self.assertIsInstance(settings.backgroundsync.proxies, BackgroundSyncConfig) self.assertIsInstance(settings.backgroundsync.maintenance, BackgroundSyncConfig) + self.assertIsInstance(settings.trigger_dependencies, TriggerDependencyConfig) + self.assertFalse(settings.trigger_dependencies.enabled) def test_snmp_macro_validation_valid(self): config = SNMPConfig(snmp_community='{$VALID_COMM}', snmp_authpass='{$VALID_AUTH}', snmp_privpass='{$VALID_PRIV}') @@ -83,3 +92,18 @@ def test_snmp_macro_validation_trailing_brace_missing(self): snmp_privpass='{$OK}', ) self.assertIn("Value must start with '{$' and end with '}'", str(ctx.exception)) + + def test_trigger_dependency_level_description_validation(self): + with self.assertRaises(ValidationError) as ctx: + TriggerDependencyLevelConfig(name='access_point', roles=['ap'], trigger_description=' ') + self.assertIn('Value must be a non-empty string', str(ctx.exception)) + + def test_trigger_dependency_level_role_validation(self): + with self.assertRaises(ValidationError) as ctx: + TriggerDependencyLevelConfig(name='access_point', roles=[], trigger_description='AP status') + self.assertIn('Role tokens must be a non-empty list', str(ctx.exception)) + + def test_trigger_dependency_levels_validation(self): + with self.assertRaises(ValidationError) as ctx: + TriggerDependencyConfig(levels=[]) + self.assertIn('Dependency levels must be a non-empty list', str(ctx.exception)) diff --git a/nbxsync/tests/utils/test_trigger_dependency_sync.py b/nbxsync/tests/utils/test_trigger_dependency_sync.py new file mode 100644 index 00000000..5bada30a --- /dev/null +++ b/nbxsync/tests/utils/test_trigger_dependency_sync.py @@ -0,0 +1,78 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import TestCase + +from nbxsync.settings import PluginSettingsModel +from nbxsync.utils.trigger_dependency_sync import build_dependency_payload, get_dependency_level, normalized_role_tokens, sync_device_trigger_dependencies + + +class TriggerDependencySyncTestCase(TestCase): + def setUp(self): + self.trigger_config = PluginSettingsModel().trigger_dependencies + + def test_normalized_role_tokens_reads_name_and_slug(self): + device = SimpleNamespace(role=SimpleNamespace(name='Meraki MX', slug='meraki-mx')) + + self.assertEqual(normalized_role_tokens(device), {'meraki mx', 'meraki-mx'}) + + def test_role_matching_uses_configured_level_tokens(self): + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + switch = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + gateway = SimpleNamespace(role=SimpleNamespace(name='Security Appliance', slug='security-appliance')) + firewall = SimpleNamespace(role=SimpleNamespace(name='Firewall', slug='firewall')) + + access_point_index, access_point_level = get_dependency_level(access_point, trigger_config=self.trigger_config) + switch_index, switch_level = get_dependency_level(switch, trigger_config=self.trigger_config) + gateway_index, gateway_level = get_dependency_level(gateway, trigger_config=self.trigger_config) + firewall_index, firewall_level = get_dependency_level(firewall, trigger_config=self.trigger_config) + + self.assertEqual((access_point_index, access_point_level.name), (0, 'access_point')) + self.assertEqual((switch_index, switch_level.name), (1, 'switch')) + self.assertEqual((gateway_index, gateway_level.name), (2, 'gateway')) + self.assertEqual((firewall_index, firewall_level.name), (2, 'gateway')) + + def test_build_dependency_payload_preserves_unmanaged_dependencies(self): + child_trigger = { + 'triggerid': '100', + 'dependencies': [ + {'triggerid': '200', 'description': 'Unrelated dependency'}, + {'triggerid': '201', 'description': self.trigger_config.levels[1].trigger_description}, + ], + } + + payload = build_dependency_payload( + child_trigger, + ['300'], + {self.trigger_config.levels[1].trigger_description}, + ) + + self.assertEqual(payload, [{'triggerid': '200'}, {'triggerid': '300'}]) + + def test_build_dependency_payload_deduplicates_parent_dependencies(self): + child_trigger = { + 'triggerid': '100', + 'dependencies': [ + {'triggerid': '300', 'description': 'Unrelated dependency'}, + ], + } + + payload = build_dependency_payload(child_trigger, ['300'], set()) + + self.assertEqual(payload, [{'triggerid': '300'}]) + + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices', return_value=[]) + @patch('nbxsync.utils.trigger_dependency_sync._sync_child_dependency', return_value={'child': 'ap', 'changed': True}) + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_device_trigger_dependencies_syncs_lowest_level_device(self, mock_settings, mock_sync_child, _mock_get_children): + self.trigger_config.enabled = True + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + access_point = SimpleNamespace( + role=SimpleNamespace(name='Access Point', slug='access-point'), + _meta=SimpleNamespace(model_name='device'), + ) + + result = sync_device_trigger_dependencies(access_point) + + self.assertEqual(result, [{'child': 'ap', 'changed': True}]) + mock_sync_child.assert_called_once_with(access_point, trigger_config=self.trigger_config) diff --git a/nbxsync/utils/trigger_dependency_sync.py b/nbxsync/utils/trigger_dependency_sync.py new file mode 100644 index 00000000..4c2b2ae3 --- /dev/null +++ b/nbxsync/utils/trigger_dependency_sync.py @@ -0,0 +1,255 @@ +import logging + +from django.contrib.contenttypes.models import ContentType + +from nbxsync.models import ZabbixServerAssignment +from nbxsync.settings import get_plugin_settings +from nbxsync.utils import ZabbixConnection + +logger = logging.getLogger(__name__) + + +def _token_set(values): + tokens = set() + for value in values or []: + normalized = str(value).strip().lower() + if normalized: + tokens.add(normalized) + tokens.add(normalized.replace('-', ' ')) + return tokens + + +def normalized_role_tokens(device): + role = getattr(device, 'role', None) + if not role: + return set() + + tokens = set() + for attr in ('name', 'slug'): + value = getattr(role, attr, None) + if value: + normalized = str(value).strip().lower() + tokens.add(normalized) + tokens.add(normalized.replace('-', ' ')) + return tokens + + +def get_dependency_level(device, trigger_config=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + role_tokens = normalized_role_tokens(device) + + for index, level in enumerate(trigger_config.levels): + if role_tokens & _token_set(level.roles): + return index, level + + return None, None + + +def get_managed_trigger_descriptions(trigger_config=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + return {level.trigger_description for level in trigger_config.levels} + + +def get_server_assignments(device): + object_ct = ContentType.objects.get_for_model(device) + return list( + ZabbixServerAssignment.objects.filter( + assigned_object_type=object_ct, + assigned_object_id=device.pk, + sync_enabled=True, + zabbixserver__sync_enabled=True, + ).select_related('zabbixserver') + ) + + +def get_host_assignment(device): + assignments = [assignment for assignment in get_server_assignments(device) if assignment.hostid] + return assignments[0] if assignments else None + + +def get_primary_remote_device(interface): + endpoints = list(interface.connected_endpoints or []) + if not endpoints: + return None + + remote_interface = endpoints[0] + return getattr(remote_interface, 'device', None) + + +def get_connected_devices(device): + devices = [] + seen_ids = set() + + for interface in device.interfaces.all().order_by('name'): + remote_device = get_primary_remote_device(interface) + if not remote_device or remote_device.pk in seen_ids: + continue + + devices.append(remote_device) + seen_ids.add(remote_device.pk) + + return devices + + +def get_parent_devices(child, trigger_config=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + child_level_index, _ = get_dependency_level(child, trigger_config=trigger_config) + if child_level_index is None: + return [] + + parents = [] + for remote_device in get_connected_devices(child): + remote_level_index, _ = get_dependency_level(remote_device, trigger_config=trigger_config) + if remote_level_index is not None and remote_level_index > child_level_index: + parents.append(remote_device) + + return parents + + +def get_child_devices(parent, trigger_config=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + parent_level_index, _ = get_dependency_level(parent, trigger_config=trigger_config) + if parent_level_index is None: + return [] + + children = [] + for remote_device in get_connected_devices(parent): + remote_level_index, _ = get_dependency_level(remote_device, trigger_config=trigger_config) + if remote_level_index is not None and remote_level_index < parent_level_index: + children.append(remote_device) + + return children + + +def get_host_trigger(api, hostid, description): + triggers = api.trigger.get( + hostids=[str(hostid)], + filter={'description': description}, + output=['triggerid', 'description'], + selectDependencies='extend', + expandDescription=True, + ) + return triggers[0] if triggers else None + + +def build_dependency_payload(child_trigger, parent_triggerids, managed_parent_descriptions): + dependencies = child_trigger.get('dependencies', []) + merged = [] + seen_ids = set() + managed_parent_descriptions = set(managed_parent_descriptions) + + for dependency in dependencies: + dependency_id = dependency.get('triggerid') + dependency_description = dependency.get('description') + if not dependency_id or dependency_description in managed_parent_descriptions: + continue + if dependency_id in seen_ids: + continue + merged.append({'triggerid': dependency_id}) + seen_ids.add(dependency_id) + + for parent_triggerid in parent_triggerids: + parent_triggerid = str(parent_triggerid) + if parent_triggerid not in seen_ids: + merged.append({'triggerid': parent_triggerid}) + seen_ids.add(parent_triggerid) + + return merged + + +def sync_device_trigger_dependencies(device): + trigger_config = get_plugin_settings().trigger_dependencies + + if not trigger_config.enabled: + return [] + + if getattr(device._meta, 'model_name', None) != 'device': + logger.debug('Skipping trigger dependency sync for unsupported object type: %s', device) + return [] + + device_level_index, _ = get_dependency_level(device, trigger_config=trigger_config) + if device_level_index is None: + logger.debug('Skipping trigger dependency sync for unsupported role on %s.', device) + return [] + + results = [] + + if device_level_index < len(trigger_config.levels) - 1: + result = _sync_child_dependency(device, trigger_config=trigger_config) + if result: + results.append(result) + + for child in get_child_devices(device, trigger_config=trigger_config): + result = _sync_child_dependency(child, trigger_config=trigger_config) + if result: + results.append(result) + + return results + + +def _sync_child_dependency(child, trigger_config=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + child_level_index, child_level = get_dependency_level(child, trigger_config=trigger_config) + if child_level_index is None: + logger.debug('Skipping dependency sync for unsupported child role on %s.', child) + return None + + parent_devices = get_parent_devices(child, trigger_config=trigger_config) + if not parent_devices: + logger.warning('No higher-level connected parent found for %s; skipping dependency sync.', child) + return None + + child_assignment = get_host_assignment(child) + if not child_assignment: + logger.warning('No Zabbix host assignment with hostid found for %s.', child) + return None + + with ZabbixConnection(child_assignment.zabbixserver) as api: + child_trigger = get_host_trigger(api, child_assignment.hostid, child_level.trigger_description) + if not child_trigger: + logger.warning('No "%s" trigger found for %s.', child_level.trigger_description, child) + return None + + parent_triggers = [] + parent_names = [] + for parent in parent_devices: + _, parent_level = get_dependency_level(parent, trigger_config=trigger_config) + parent_assignment = get_host_assignment(parent) + if not parent_assignment: + logger.warning('No Zabbix host assignment with hostid found for parent %s.', parent) + continue + + if child_assignment.zabbixserver_id != parent_assignment.zabbixserver_id: + logger.warning( + 'Skipping dependency sync for %s: child and parent %s are assigned to different Zabbix servers.', + child, + parent, + ) + continue + + parent_trigger = get_host_trigger(api, parent_assignment.hostid, parent_level.trigger_description) + if not parent_trigger: + logger.warning('No "%s" trigger found for %s.', parent_level.trigger_description, parent) + continue + + parent_triggers.append(parent_trigger) + parent_names.append(str(parent)) + + if not parent_triggers: + return None + + dependency_payload = build_dependency_payload( + child_trigger, + [trigger['triggerid'] for trigger in parent_triggers], + get_managed_trigger_descriptions(trigger_config=trigger_config), + ) + current_ids = {dep.get('triggerid') for dep in child_trigger.get('dependencies', []) if dep.get('triggerid')} + desired_ids = {dep.get('triggerid') for dep in dependency_payload} + + if current_ids == desired_ids: + logger.info('Dependency already correct: %s -> %s.', child, ', '.join(parent_names)) + return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': False} + + api.trigger.update(triggerid=child_trigger['triggerid'], dependencies=dependency_payload) + logger.info('Updated dependency: %s -> %s.', child, ', '.join(parent_names)) + return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': True} From 8894b57d3a0117ae42928442c32da75dd347b17c Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Sun, 21 Jun 2026 22:15:54 +0200 Subject: [PATCH 06/40] Update docs to include the changelog for 1.0.4 --- docs/changelog.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 40aeac74..a0e585c9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -84,6 +84,24 @@ None None +## [1.0.4] - Minor update + +- Updated the documentation + +### New features + +- Updated the Zabbix Template Assignment and Zabbix Tag Assignment forms so no duplicate templates/tags can't be assigned ([#78]) +- Zabbix Macro's now support Jinja2 templated values ([#83]) + +### Bug fixes + +- Fixed issue with internationalization of field set names on forms ([#39]) +- Fixed typo in last_sync_message ([#77]) +- Fixed issue with API Schema not being able to generated ([#79]) +- Manual device sync fails for templated Zabbix hostgroups when the rendered local hostgroup does not already exist ([#81]) +- Ensure that the worker doesn't crash on certain race conditions with regards to the hostinterfacesync job ([#86]) +- Fix issue with DeleteHost so it now actually removed the device/object from Zabbix when its deleted from NetBox ([#88]) + [#5]: https://github.com/OpensourceICTSolutions/nbxsync/issues/5 [#20]: https://github.com/OpensourceICTSolutions/nbxsync/issues/20 @@ -105,4 +123,10 @@ None [#66]: https://github.com/OpensourceICTSolutions/nbxsync/issues/66 [#68]: https://github.com/OpensourceICTSolutions/nbxsync/issues/68 [#71]: https://github.com/OpensourceICTSolutions/nbxsync/issues/71 -[#74]: https://github.com/OpensourceICTSolutions/nbxsync/issues/74 \ No newline at end of file +[#74]: https://github.com/OpensourceICTSolutions/nbxsync/issues/74 +[#77]: https://github.com/OpensourceICTSolutions/nbxsync/issues/77 +[#78]: https://github.com/OpensourceICTSolutions/nbxsync/issues/78 +[#79]: https://github.com/OpensourceICTSolutions/nbxsync/issues/79 +[#81]: https://github.com/OpensourceICTSolutions/nbxsync/issues/81 +[#86]: https://github.com/OpensourceICTSolutions/nbxsync/issues/86 +[#88]: https://github.com/OpensourceICTSolutions/nbxsync/issues/88 \ No newline at end of file From ced03a9c316f7d94f1f923eeb31f843804fbaf7a Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Sun, 21 Jun 2026 23:20:53 +0200 Subject: [PATCH 07/40] Fixed issue where syncing to multiple Zabbix Servers failed ([#90]) --- nbxsync/jobs/synchost.py | 6 +++--- nbxsync/utils/inheritance.py | 39 +++++++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/nbxsync/jobs/synchost.py b/nbxsync/jobs/synchost.py index 408fea0c..4e237176 100644 --- a/nbxsync/jobs/synchost.py +++ b/nbxsync/jobs/synchost.py @@ -41,16 +41,16 @@ def delete_host(self, assignment): safe_delete(HostSync, assignment) def verify_hostinterfaces(self, assignment): - all_objects = get_assigned_zabbixobjects(self.instance) + all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) run_zabbix_operation(HostSync, assignment, 'verify_hostinterfaces', extra_args={'all_objects': all_objects}) def check_default_hostinterface(self, assignment): - all_objects = get_assigned_zabbixobjects(self.instance) + all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) run_zabbix_operation(HostSync, assignment, 'check_default_hostinterface', extra_args={'all_objects': all_objects}) def sync_host(self, assignment): try: - all_objects = get_assigned_zabbixobjects(self.instance) + all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) # Add the assigned_objects attribute, so we dont have to do this expensive calculation again later on :) assignment.assigned_objects = all_objects diff --git a/nbxsync/utils/inheritance.py b/nbxsync/utils/inheritance.py index 59b50b7b..133caa16 100644 --- a/nbxsync/utils/inheritance.py +++ b/nbxsync/utils/inheritance.py @@ -37,22 +37,40 @@ def table_or_none(data, table_cls, attach_instance=False): } -def get_assigned_zabbixobjects(instance): +def get_assigned_zabbixobjects(instance, zabbixserver=None): """ Return raw Zabbix assignment lists (direct + inherited) without any table formatting. + + When *zabbixserver* is given, server-scoped objects (templates, hostgroups, + host interfaces) are filtered to that server only. Macros, tags, inventory + and configuration-group assignments are server-agnostic and returned + unfiltered. """ content_type = ContentType.objects.get_for_model(instance) - # Direct assignments - direct_templates = list(ZabbixTemplateAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixtemplate')) + # Direct assignments — server-scoped querysets are filtered conditionally + templates_qs = ZabbixTemplateAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixtemplate') + if zabbixserver: + templates_qs = templates_qs.filter(zabbixtemplate__zabbixserver=zabbixserver) + direct_templates = list(templates_qs) + direct_macros = list(ZabbixMacroAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixmacro')) direct_tags = list(ZabbixTagAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixtag')) - direct_hostgroups = list(ZabbixHostgroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixhostgroup')) - hostinterfaces = list(ZabbixHostInterface.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id)) + + hostgroups_qs = ZabbixHostgroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixhostgroup') + if zabbixserver: + hostgroups_qs = hostgroups_qs.filter(zabbixhostgroup__zabbixserver=zabbixserver) + direct_hostgroups = list(hostgroups_qs) + + hostinterfaces_qs = ZabbixHostInterface.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id) + if zabbixserver: + hostinterfaces_qs = hostinterfaces_qs.filter(zabbixserver=zabbixserver) + hostinterfaces = list(hostinterfaces_qs) + hostinventory = ZabbixHostInventory.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).first() configurationgroup = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).first() - inherited = resolve_inherited_zabbix_assignments(instance) + inherited = resolve_inherited_zabbix_assignments(instance, zabbixserver) def merge(direct, inherited_map, key): direct_ids = {getattr(obj, key) for obj in direct} @@ -70,7 +88,7 @@ def merge(direct, inherited_map, key): } -def resolve_inherited_zabbix_assignments(assigned_object): +def resolve_inherited_zabbix_assignments(assigned_object, zabbixserver=None): resolved_templates = OrderedDict() resolved_macros = OrderedDict() resolved_tags = OrderedDict() @@ -91,7 +109,7 @@ def resolve_path(obj, path): # If the attribute is a manager or queryset, take the first related object if isinstance(cur, (BaseManager, QuerySet)): cur = cur.first() - # If it’s something that still isn’t a model instance after collapsing, bail + # If it's something that still isn't a model instance after collapsing, bail if cur is None: return None return cur @@ -108,11 +126,14 @@ def resolve_path(obj, path): ct = ContentType.objects.get_for_model(related_obj) templates = ZabbixTemplateAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixtemplate') macros = ZabbixMacroAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixmacro') - tags = ZabbixTagAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixtag') hostgroups = ZabbixHostgroupAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixhostgroup') configurationgroups = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixconfigurationgroup') + if zabbixserver: + templates = templates.filter(zabbixtemplate__zabbixserver=zabbixserver) + hostgroups = hostgroups.filter(zabbixhostgroup__zabbixserver=zabbixserver) + # print(f'[Resolved from {label}] {related_obj}: inherited {len(templates)} templates, {len(macros)} macros, {len(tags)} tags, {len(hostgroups)} hostgroups, {len(configurationgroups)} configurationgroups,') for template in templates: From 59b77f936448c06adc0c133d4201500696d9805a Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Sun, 21 Jun 2026 23:21:21 +0200 Subject: [PATCH 08/40] ixed issue where local_address was cleared, even when the proxy is part of a ProxyGroup , removed duplicate line, Fixed typo ( vs ) in ProxySync ([#91]) --- nbxsync/models/zabbixproxy.py | 9 +++++---- nbxsync/tests/models/test_zabbixproxy.py | 2 +- nbxsync/tests/utils/test_proxysync.py | 2 +- nbxsync/utils/sync/proxysync.py | 3 +-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/nbxsync/models/zabbixproxy.py b/nbxsync/models/zabbixproxy.py index 889341f4..87dedd94 100644 --- a/nbxsync/models/zabbixproxy.py +++ b/nbxsync/models/zabbixproxy.py @@ -62,8 +62,8 @@ def clean(self): errors = {} # Validate local_address field - if not self.local_address and self.proxygroup is not None and self.operating_mode == ZabbixProxyTypeChoices.ACTIVE: - errors['local_address'] = _('Local Address must be specified when part of a ProxyGroup and Operating Mode is set to Active') + if not self.local_address and self.proxygroup is not None: + errors['local_address'] = _('Local Address must be specified when part of a ProxyGroup') # Validate if local_address is valid if self.local_address is not None and self.operating_mode == ZabbixProxyTypeChoices.ACTIVE: @@ -151,8 +151,9 @@ def save(self, *args, **kwargs): self.tls_connect = ZabbixTLSChoices.NO_ENCRYPTION if self.operating_mode == ZabbixProxyTypeChoices.PASSIVE: - self.local_address = '' - self.local_port = 10051 + if not self.proxygroup: + self.local_address = '' + self.local_port = 10051 self.allowed_addresses = [] self.tls_accept = [ZabbixTLSChoices.NO_ENCRYPTION] diff --git a/nbxsync/tests/models/test_zabbixproxy.py b/nbxsync/tests/models/test_zabbixproxy.py index 90698904..d6a8ce47 100644 --- a/nbxsync/tests/models/test_zabbixproxy.py +++ b/nbxsync/tests/models/test_zabbixproxy.py @@ -163,4 +163,4 @@ def test_active_with_group_requires_local_address(self): proxy.full_clean() self.assertIn('local_address', cm.exception.message_dict) - self.assertIn('Local Address must be specified when part of a ProxyGroup and Operating Mode is set to Active', cm.exception.message_dict['local_address'][0]) + self.assertIn('Local Address must be specified when part of a ProxyGroup', cm.exception.message_dict['local_address'][0]) diff --git a/nbxsync/tests/utils/test_proxysync.py b/nbxsync/tests/utils/test_proxysync.py index 5e02954e..4869e878 100644 --- a/nbxsync/tests/utils/test_proxysync.py +++ b/nbxsync/tests/utils/test_proxysync.py @@ -80,7 +80,7 @@ def test_sync_from_zabbix_sets_fields(self, mock_get_group): self.assertEqual(self.zabbix_proxy.proxyid, '200') self.assertEqual(self.zabbix_proxy.name, 'Synced Proxy') self.assertEqual(self.zabbix_proxy.tls_psk_identity, 'updated-id') - self.assertEqual(self.zabbix_proxy.tls_accept, [1]) + self.assertEqual(self.zabbix_proxy.tls_accept, [2, 4]) self.assertEqual(self.zabbix_proxy.allowed_addresses, ['192.168.1.5', '192.168.1.6']) self.zabbix_proxy.save.assert_called_once() diff --git a/nbxsync/utils/sync/proxysync.py b/nbxsync/utils/sync/proxysync.py index 6280207e..3cf5562c 100644 --- a/nbxsync/utils/sync/proxysync.py +++ b/nbxsync/utils/sync/proxysync.py @@ -60,7 +60,6 @@ def get_create_params(self): create_params['tls_subject'] = self.obj.tls_subject if self.obj.custom_timeouts: - create_params['timeout_zabbix_agent'] = self.obj.timeout_zabbix_agent create_params['timeout_zabbix_agent'] = self.obj.timeout_zabbix_agent create_params['timeout_simple_check'] = self.obj.timeout_simple_check create_params['timeout_snmp_agent'] = self.obj.timeout_snmp_agent @@ -86,7 +85,7 @@ def sync_from_zabbix(self, data): tls_accept = [1] # Default to 'No Encryption' if data.get('tls_accept', 1): flags = (1, 2, 4) - value = int(data.get('tls_acept', 1)) + value = int(data.get('tls_accept', 1)) tls_accept = [flag for flag in flags if value & flag] self.obj.proxyid = data.get('proxyid') From 0b2865e0ae1efb17b2ad8d9e418e1e3c4a4a96dd Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 24 Jun 2026 20:10:32 +0200 Subject: [PATCH 09/40] prep --- docs/changelog.md | 21 ++++++++++++++++++++- nbxsync/__init__.py | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index a0e585c9..60f87675 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -102,6 +102,22 @@ None - Ensure that the worker doesn't crash on certain race conditions with regards to the hostinterfacesync job ([#86]) - Fix issue with DeleteHost so it now actually removed the device/object from Zabbix when its deleted from NetBox ([#88]) +## [1.0.5] - Minor update + +### New features + +- + +### Bug fixes + +- Fixed issue where syncing to multiple Zabbix Servers failed ([#90]) +- Fixed issue where local_address was cleared, even when the proxy is part of a ProxyGroup ([#91]) +- Removed duplicate line in ProxySync ([#91]) +- Fixed typo (`acept` vs `accept`) in ProxySync ([#91]) + +### Breaking changes + +- Dropped support for NetBox < 4.2.6 in order to support NetBox 4.6.X ([#98]) [#5]: https://github.com/OpensourceICTSolutions/nbxsync/issues/5 [#20]: https://github.com/OpensourceICTSolutions/nbxsync/issues/20 @@ -129,4 +145,7 @@ None [#79]: https://github.com/OpensourceICTSolutions/nbxsync/issues/79 [#81]: https://github.com/OpensourceICTSolutions/nbxsync/issues/81 [#86]: https://github.com/OpensourceICTSolutions/nbxsync/issues/86 -[#88]: https://github.com/OpensourceICTSolutions/nbxsync/issues/88 \ No newline at end of file +[#88]: https://github.com/OpensourceICTSolutions/nbxsync/issues/88 +[#90]: https://github.com/OpensourceICTSolutions/nbxsync/issues/90 +[#91]: https://github.com/OpensourceICTSolutions/nbxsync/issues/91 +[#98]: https://github.com/OpensourceICTSolutions/nbxsync/issues/98 diff --git a/nbxsync/__init__.py b/nbxsync/__init__.py index 7311d8d4..fdca0fe7 100644 --- a/nbxsync/__init__.py +++ b/nbxsync/__init__.py @@ -38,7 +38,7 @@ class nbxSync(PluginConfig): author = name author_email = email base_url = 'nbxsync' - min_version = '4.2.4' + min_version = '4.2.6' required_settings = [] default_settings = { 'sot': { From e752a17d18e62c4032b28114cc53bb940d53ffbf Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 24 Jun 2026 21:34:05 +0200 Subject: [PATCH 10/40] Trigger fix --- nbxsync/tests/query_counts.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nbxsync/tests/query_counts.json b/nbxsync/tests/query_counts.json index c45c5a92..a0459c74 100644 --- a/nbxsync/tests/query_counts.json +++ b/nbxsync/tests/query_counts.json @@ -15,5 +15,4 @@ "zabbixtagassignment:list_objects_with_permission": 20, "zabbixtemplate:list_objects_with_permission": 19, "zabbixtemplateassignment:list_objects_with_permission": 20 -} - +} \ No newline at end of file From 148c8dc1e5ac5866196f4dc57df026310c78726d Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Sun, 28 Jun 2026 21:57:26 +0200 Subject: [PATCH 11/40] Fix test --- nbxsync/tests/jobs/test_synchost.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index 9ed526dc..57301928 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -11,7 +11,7 @@ from nbxsync.choices.zabbixstatus import ZabbixHostStatus from nbxsync.jobs.synchost import SyncHostJob from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixProxy, ZabbixProxyGroup, ZabbixServer, ZabbixServerAssignment -from nbxsync.utils.sync import HostSync, ProxyGroupSync, HostInterfaceSync +from nbxsync.utils.sync import ProxyGroupSync class SyncHostJobTestCase(TestCase): @@ -21,7 +21,7 @@ def setUp(self): self.zabbixserver = ZabbixServer.objects.create(name='Zabbix1', url='http://zabbix.local', token='abc123') - self.proxygroup = ZabbixProxyGroup.objects.create(name='Test Proxy Group', zabbixserver=self.zabbixserver, proxy_groupid=99) + self.proxygroup = ZabbixProxyGroup.objects.create(failover_delay="1m", name='Test Proxy Group123', zabbixserver=self.zabbixserver, proxy_groupid=99, ) self.proxy = ZabbixProxy.objects.create( name='Active Proxy #1', zabbixserver=self.zabbixserver, @@ -90,7 +90,7 @@ def setUp(self): } ] mock_api.hostgroup.get.return_value = [{'groupid': '1'}] - mock_api.proxygroup.get.return_value = [{'proxy_groupid': 99}] + mock_api.proxygroup.get.return_value = [{'proxy_groupid': 99, 'failover_delay': '1m', 'min_online': 1}] mock_api.proxygroup.create.return_value = {'proxy_groupids': [99]} # Assign API to context manager return From 3543ffeb059d50e25235f757471631e432e9548e Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Sun, 28 Jun 2026 21:57:57 +0200 Subject: [PATCH 12/40] Fix missing zabbixserver attribute + invalid day parameter for non-MONTHLY timeperiods --- nbxsync/jobs/syncmaintenance.py | 1 + nbxsync/utils/sync/maintenancesync.py | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/nbxsync/jobs/syncmaintenance.py b/nbxsync/jobs/syncmaintenance.py index b15c1304..6f40c98e 100644 --- a/nbxsync/jobs/syncmaintenance.py +++ b/nbxsync/jobs/syncmaintenance.py @@ -7,6 +7,7 @@ class SyncMaintenceJob: def __init__(self, **kwargs): self.instance = kwargs.get('instance') + self.zabbixserver = self.instance.zabbixserver def run(self): if not self.zabbixserver.sync_enabled: diff --git a/nbxsync/utils/sync/maintenancesync.py b/nbxsync/utils/sync/maintenancesync.py index 4a57da83..2deb04af 100644 --- a/nbxsync/utils/sync/maintenancesync.py +++ b/nbxsync/utils/sync/maintenancesync.py @@ -103,12 +103,13 @@ def get_timeperiods(self): # Bitwise OR, not just sum(). timeperiod_result['month'] |= x - if not timeperiod.dayofweek: - timeperiod_result['day'] = timeperiod.day - else: - timeperiod_result['dayofweek'] = 0 - for x in timeperiod.dayofweek: - timeperiod_result['dayofweek'] |= x + if timeperiod.timeperiod_type == ZabbixTimePeriodTypeChoices.MONTHLY: + if not timeperiod.dayofweek: + timeperiod_result['day'] = timeperiod.day + else: + timeperiod_result['dayofweek'] = 0 + for x in timeperiod.dayofweek: + timeperiod_result['dayofweek'] |= x result.append(timeperiod_result) return result From 4f56004c0ab2df67ab0de7360290dfa3a553c563 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 12:12:02 +0200 Subject: [PATCH 13/40] Fix typo in SyncMaintenceJob --- nbxsync/jobs/syncmaintenance.py | 4 ++-- nbxsync/worker.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nbxsync/jobs/syncmaintenance.py b/nbxsync/jobs/syncmaintenance.py index 6f40c98e..a196c0d9 100644 --- a/nbxsync/jobs/syncmaintenance.py +++ b/nbxsync/jobs/syncmaintenance.py @@ -1,10 +1,10 @@ from nbxsync.utils.sync import MaintenanceSync from nbxsync.utils.sync.safe_sync import safe_sync -__all__ = ('SyncMaintenceJob',) +__all__ = ('SyncMaintenanceJob',) -class SyncMaintenceJob: +class SyncMaintenanceJob: def __init__(self, **kwargs): self.instance = kwargs.get('instance') self.zabbixserver = self.instance.zabbixserver diff --git a/nbxsync/worker.py b/nbxsync/worker.py index 9a822504..b4a62201 100644 --- a/nbxsync/worker.py +++ b/nbxsync/worker.py @@ -39,7 +39,7 @@ def synctemplates(instance): @job('low') def syncmaintenance(instance): - worker = SyncMaintenceJob(instance=instance) + worker = SyncMaintenanceJob(instance=instance) worker.run() From da7c1224015387957dbd1328d1f809ae9f19e5dd Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 12:16:52 +0200 Subject: [PATCH 14/40] Fix issue where only 1 ZabbixServer would be synced --- nbxsync/jobs/deletehost.py | 2 +- nbxsync/jobs/synchost.py | 2 +- nbxsync/systemjobs/sync_objects.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nbxsync/jobs/deletehost.py b/nbxsync/jobs/deletehost.py index 56f4ff76..7994db26 100644 --- a/nbxsync/jobs/deletehost.py +++ b/nbxsync/jobs/deletehost.py @@ -17,7 +17,7 @@ def run(self): for assignment in zabbixserver_assignments: if not assignment.sync_enabled or not assignment.zabbixserver.sync_enabled: - return + continue self.delete_host(assignment) def delete_host(self, assignment): diff --git a/nbxsync/jobs/synchost.py b/nbxsync/jobs/synchost.py index 4e237176..dd96d54b 100644 --- a/nbxsync/jobs/synchost.py +++ b/nbxsync/jobs/synchost.py @@ -28,7 +28,7 @@ def run(self): for assignment in zabbixserver_assignments: if not assignment.sync_enabled or not assignment.zabbixserver.sync_enabled: - return + continue if zabbix_status == ZabbixHostStatus.DELETED: self.delete_host(assignment) diff --git a/nbxsync/systemjobs/sync_objects.py b/nbxsync/systemjobs/sync_objects.py index 76cee240..b872f32d 100644 --- a/nbxsync/systemjobs/sync_objects.py +++ b/nbxsync/systemjobs/sync_objects.py @@ -24,7 +24,7 @@ def run(self, *args, **kwargs): continue if obj.assigned_object in synced_objects: - return + continue else: synced_objects.append(obj.assigned_object) From 356911589659601473bbca2517c1dcbe25de9149 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 12:21:12 +0200 Subject: [PATCH 15/40] Fix validation error on Zabbix maintenance object assignment --- nbxsync/models/zabbixmaintenanceobjectassignment.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/nbxsync/models/zabbixmaintenanceobjectassignment.py b/nbxsync/models/zabbixmaintenanceobjectassignment.py index 1952cb0e..78e30526 100644 --- a/nbxsync/models/zabbixmaintenanceobjectassignment.py +++ b/nbxsync/models/zabbixmaintenanceobjectassignment.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ from django.db import models from netbox.models import NetBoxModel @@ -33,8 +34,12 @@ class Meta: def clean(self): super().clean() - if type(self.assigned_object_type) == ZabbixHostgroup and self.assigned_object.is_template(): - raise ValidationError('No templated hostgroup is allowed!') + if self.assigned_object_type is None or self.assigned_object_id is None: + # Nothing to check yet; let the FK/id validators handle emptiness. + return + + if isinstance(self.assigned_object, ZabbixHostgroup) and self.assigned_object.is_template(): + raise ValidationError({'assigned_object_id': _('A templated Zabbix Hostgroup cannot be assigned to a maintenance window.')}) def __str__(self): return f'{self.assigned_object} ({self.zabbixmaintenance})' From 4e8e929afce7d5afc1d71d16052bd9b035a8b3bc Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 12:22:58 +0200 Subject: [PATCH 16/40] Ruff cleanup --- nbxsync/settings.py | 1 - nbxsync/tests/jobs/test_synchost.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/nbxsync/settings.py b/nbxsync/settings.py index ebcd0c2f..a4094f72 100644 --- a/nbxsync/settings.py +++ b/nbxsync/settings.py @@ -111,7 +111,6 @@ class PluginSettingsModel(BaseModel): objtag_type: str = Field(default='nb_type') objtag_id: str = Field(default='nb_id') - custom_field_hostname: str = Field(default='') custom_field_display_name: str = Field(default='') diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index 57301928..d32959b0 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -21,7 +21,7 @@ def setUp(self): self.zabbixserver = ZabbixServer.objects.create(name='Zabbix1', url='http://zabbix.local', token='abc123') - self.proxygroup = ZabbixProxyGroup.objects.create(failover_delay="1m", name='Test Proxy Group123', zabbixserver=self.zabbixserver, proxy_groupid=99, ) + self.proxygroup = ZabbixProxyGroup.objects.create(failover_delay='1m', name='Test Proxy Group123', zabbixserver=self.zabbixserver, proxy_groupid=99) self.proxy = ZabbixProxy.objects.create( name='Active Proxy #1', zabbixserver=self.zabbixserver, From 9d7816db5ed6cabeb3c1c77eb8bf98fe9c8e37a6 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 12:26:14 +0200 Subject: [PATCH 17/40] Fix path label for configuration group --- nbxsync/constants/path_labels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nbxsync/constants/path_labels.py b/nbxsync/constants/path_labels.py index 20a1139f..1f2b1b41 100644 --- a/nbxsync/constants/path_labels.py +++ b/nbxsync/constants/path_labels.py @@ -1,7 +1,7 @@ PATH_LABELS = { ('device_type',): 'Device Type', ('device_type', 'manufacturer'): 'Manufacturer', - ('zabbixconfigurationgroup'): 'Zabbix Configuration Group', + ('zabbixconfigurationgroup',): 'Zabbix Configuration Group', ('platform',): 'Platform', ('role',): 'Role', ('cluster',): 'Cluster', From 249a99f36d205c0c9b95d0238a59dc91549745fc Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 13:26:21 +0200 Subject: [PATCH 18/40] Fixed hostinfo details --- nbxsync/templates/nbxsync/modals/op_view.html | 23 +++ nbxsync/tests/views/test_hostinfo.py | 100 ++++++++++ nbxsync/views/hostinfo.py | 183 ++++++++++-------- 3 files changed, 221 insertions(+), 85 deletions(-) create mode 100644 nbxsync/tests/views/test_hostinfo.py diff --git a/nbxsync/templates/nbxsync/modals/op_view.html b/nbxsync/templates/nbxsync/modals/op_view.html index 7853b54b..19140fee 100644 --- a/nbxsync/templates/nbxsync/modals/op_view.html +++ b/nbxsync/templates/nbxsync/modals/op_view.html @@ -1,2 +1,25 @@ +{% load i18n %} {% load render_table from django_tables2 %} +{% if fetch_errors %} + +{% endif %} {% render_table table %} diff --git a/nbxsync/tests/views/test_hostinfo.py b/nbxsync/tests/views/test_hostinfo.py new file mode 100644 index 00000000..9cb7f39b --- /dev/null +++ b/nbxsync/tests/views/test_hostinfo.py @@ -0,0 +1,100 @@ +from unittest.mock import MagicMock, patch + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.urls import reverse + +from dcim.models import Device +from utilities.testing import create_test_device +from nbxsync.models import ZabbixServer, ZabbixServerAssignment + + +def _make_event(eventid, clock, r_eventid='0', **extra): + base = { + 'eventid': eventid, + 'clock': clock, + 'r_eventid': r_eventid, + 'name': f'Event {eventid}', + 'severity': '4', + 'objectid': '9999', + 'acknowledged': '0', + 'opdata': '', + } + base.update(extra) + return base + + +class HostEventsViewOpenEventTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.device = create_test_device(name='dev1') + cls.server = ZabbixServer.objects.create(name='ZBX', url='http://example.com', token='test') + cls.assignment = ZabbixServerAssignment.objects.create(zabbixserver=cls.server, hostid=101, assigned_object_type=ContentType.objects.get_for_model(Device), assigned_object_id=cls.device.pk) + + @patch('nbxsync.views.hostinfo.ZabbixConnection') + def test_first_event_is_still_open_does_not_raise(self, mock_conn): + api = MagicMock() + api.event.get.return_value = [_make_event('1', '1000')] + mock_conn.return_value.__enter__.return_value = api + + response = self.client.get(reverse('plugins:nbxsync:zabbixhost_events', kwargs={'objtype': 'device', 'pk': self.device.pk})) + self.assertEqual(response.status_code, 200) + + rows = list(response.context['table'].rows) + self.assertEqual(len(rows), 1) + self.assertIsNone(rows[0].record['end_time']) + self.assertIsNone(rows[0].record['duration']) + + @patch('nbxsync.views.hostinfo.ZabbixConnection') + def test_open_event_after_recovered_event_uses_own_state(self, mock_conn): + api = MagicMock() + api.event.get.return_value = [ + _make_event('1', '1000', r_eventid='2'), # recovered + _make_event('2', '1500'), # its recovery + _make_event('3', '2000'), # open — must not inherit '1500' + ] + mock_conn.return_value.__enter__.return_value = api + + response = self.client.get(reverse('plugins:nbxsync:zabbixhost_events', kwargs={'objtype': 'device', 'pk': self.device.pk})) + rows = [r.record for r in response.context['table'].rows] + open_row = next(r for r in rows if r['eventid'] == '3') + self.assertIsNone(open_row['end_time']) + self.assertIsNone(open_row['duration']) + + +@patch('nbxsync.views.hostinfo.ZabbixConnection') +def test_events_from_multiple_servers_are_all_kept(self, mock_conn): + """ + Regression: `event_list = []` used to live inside the per-server loop, so + the second server's events wiped the first's. + """ + server_b = ZabbixServer.objects.create(name='ZBX-B', url='http://b.example', token='t') + ZabbixServerAssignment.objects.create(zabbixserver=server_b, hostid=202, assigned_object_type=ContentType.objects.get_for_model(Device), assigned_object_id=self.device.pk) + + responses = { + 101: [_make_event('1', '1000')], # from server A + 202: [_make_event('2', '2000')], # from server B + } + + def enter(instance): + # Which server this connection is for — read from the arg the view passed. + server = instance.call_args.args[0] + api = MagicMock() + api.event.get.side_effect = lambda **kw: [] if 'eventids' in kw else responses[ZabbixServerAssignment.objects.get(zabbixserver=server).hostid] + return api + + mock_conn.return_value.__enter__.side_effect = lambda: enter(mock_conn) + + response = self.client.get(reverse('plugins:nbxsync:zabbixhost_events', kwargs={'objtype': 'device', 'pk': self.device.pk})) + eventids = {r.record['eventid'] for r in response.context['table'].rows} + self.assertEqual(eventids, {'1', '2'}) + + +@patch('nbxsync.views.hostinfo.ZabbixConnection') +def test_unreachable_server_shows_in_fetch_errors(self, mock_conn): + mock_conn.side_effect = ConnectionError('boom') + + response = self.client.get(reverse('plugins:nbxsync:zabbixhost_events', kwargs={'objtype': 'device', 'pk': self.device.pk})) + self.assertEqual(response.status_code, 200) + self.assertIn(self.server, response.context['fetch_errors']) + self.assertEqual(list(response.context['table'].rows), []) diff --git a/nbxsync/views/hostinfo.py b/nbxsync/views/hostinfo.py index 540a323f..b84e3509 100644 --- a/nbxsync/views/hostinfo.py +++ b/nbxsync/views/hostinfo.py @@ -1,12 +1,51 @@ +import logging + +from django.contrib.contenttypes.models import ContentType from django.http import Http404 -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView -from django.contrib.contenttypes.models import ContentType -from nbxsync.utils import ZabbixConnection -from nbxsync.models import ZabbixServerAssignment -from nbxsync.tables import ZabbixProblemTable, ZabbixEventTable from nbxsync.constants import OBJECT_TYPE_MODEL_MAP +from nbxsync.models import ZabbixServerAssignment +from nbxsync.tables import ZabbixEventTable, ZabbixProblemTable +from nbxsync.utils import ZabbixConnection + +logger = logging.getLogger(__name__) + + +def _resolve_model_or_404(objtype): + """Look up the NetBox model for an objtype URL segment, or raise 404.""" + model = OBJECT_TYPE_MODEL_MAP.get(objtype) + if not model: + raise Http404(_('Unsupported object type: %(objtype)s') % {'objtype': objtype}) + return model + + +def _server_assignments_for(model, pk): + """Return all ZabbixServerAssignments pointing at the given (model, pk).""" + object_ct = ContentType.objects.get_for_model(model) + return ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=pk).select_related('assigned_object_type', 'zabbixserver') + + +def _event_row(assignment, event, *, end_time, duration): + """ + Build one table row from a Zabbix event dict. + + Both the recovered-event branch and the still-open-event branch use this + so they cannot drift in field shape over time. + """ + return { + 'zabbixserver': assignment.zabbixserver, + 'acknowledged': event.get('acknowledged', '0'), + 'duration': duration, + 'event': event.get('name'), + 'eventid': event.get('eventid'), + 'triggerid': event.get('objectid'), + 'severity': event.get('severity'), + 'start_time': event.get('clock'), + 'end_time': end_time, + 'opdata': event.get('opdata'), + } class ZabbixHostProblemsView(TemplateView): @@ -14,38 +53,28 @@ class ZabbixHostProblemsView(TemplateView): def get_context_data(self, objtype, pk, **kwargs): context = super().get_context_data(**kwargs) - model = OBJECT_TYPE_MODEL_MAP.get(objtype) - if not model: - raise Http404(_('Unsupported object type: %(objtype)s') % {'objtype': objtype}) - - object_ct = ContentType.objects.get_for_model(model) - zabbixserverassignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=pk).select_related('assigned_object_type') + model = _resolve_model_or_404(objtype) problem_list = [] - for zabbixserverassignment in zabbixserverassignments: - if not zabbixserverassignment.hostid: + fetch_errors = [] + + for assignment in _server_assignments_for(model, pk): + if not assignment.hostid: continue - # Get the problems for this host + try: - with ZabbixConnection(zabbixserverassignment.zabbixserver) as api: - problems = api.problem.get(hostids=zabbixserverassignment.hostid, sortfield='eventid', sortorder='DESC') + with ZabbixConnection(assignment.zabbixserver) as api: + problems = api.problem.get(hostids=assignment.hostid, sortfield='eventid', sortorder='DESC') for problem in problems: problem_list.append( - { - 'zabbixserver': zabbixserverassignment.zabbixserver, - 'eventid': problem.get('eventid'), - 'triggerid': problem.get('objectid'), - 'severity': problem.get('severity'), - 'clock': problem.get('clock'), - 'problem': problem.get('name'), - 'acknowledged': problem.get('acknowledged'), - 'opdata': problem.get('opdata'), - } + {'zabbixserver': assignment.zabbixserver, 'eventid': problem.get('eventid'), 'triggerid': problem.get('objectid'), 'severity': problem.get('severity'), 'clock': problem.get('clock'), 'problem': problem.get('name'), 'acknowledged': problem.get('acknowledged'), 'opdata': problem.get('opdata')} ) except Exception: - pass + logger.exception('Failed to fetch Zabbix problems for host %s on server %s', assignment.hostid, assignment.zabbixserver) + fetch_errors.append(assignment.zabbixserver) context['table'] = ZabbixProblemTable(problem_list) + context['fetch_errors'] = fetch_errors return context @@ -54,55 +83,60 @@ class ZabbixHostEventsView(TemplateView): def get_context_data(self, objtype, pk, **kwargs): context = super().get_context_data(**kwargs) - model = OBJECT_TYPE_MODEL_MAP.get(objtype) - if not model: - raise Http404(_('Unsupported object type: %(objtype)s') % {'objtype': objtype}) - - object_ct = ContentType.objects.get_for_model(model) - zabbixserverassignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=pk).select_related('assigned_object_type') + model = _resolve_model_or_404(objtype) + # Accumulated across all Zabbix servers this device is assigned to; + # deliberately declared outside the per-server loop so servers + # after the first are not clobbered. event_list = [] - for zabbixserverassignment in zabbixserverassignments: - if not zabbixserverassignment.hostid: + fetch_errors = [] + + for assignment in _server_assignments_for(model, pk): + if not assignment.hostid: continue - # Get the events for this host + try: - with ZabbixConnection(zabbixserverassignment.zabbixserver) as api: - events = api.event.get(hostids=zabbixserverassignment.hostid, limit=15, sortfield=['clock', 'eventid'], sortorder='DESC') + with ZabbixConnection(assignment.zabbixserver) as api: + events = api.event.get(hostids=assignment.hostid, limit=15, sortfield=['clock', 'eventid'], sortorder='DESC') - # Index by id for O(1) lookup + # Index by id for O(1) recovery lookup within this batch. by_id = {e['eventid']: e for e in events} - - event_list = [] paired = set() for event in events: - recovery_id = event.get('r_eventid', 0) + # Zabbix returns r_eventid as a string; '0' means + # "no recovery yet". Coerce to int so the truthy + # check actually works — '0' would otherwise be + # truthy and slip through as if it were a recovery. + try: + recovery_id = int(event.get('r_eventid') or 0) + except (TypeError, ValueError): + recovery_id = 0 + + if recovery_id: + recovery_key = str(recovery_id) - if recovery_id and recovery_id != 0: # Avoid double-processing the same pair - pair_key = tuple(sorted((event['eventid'], recovery_id))) + pair_key = tuple(sorted((event['eventid'], recovery_key))) if pair_key in paired: continue - # Try to find the recovery in the current batch - recovery_event = by_id.get(recovery_id) - if not recovery_event: - # Not in the 15? Fetch it directly. + # Try to find the recovery in the current batch first. + recovery_event = by_id.get(recovery_key) + if recovery_event is None: + # Not in the 15 most recent — fetch by id. try: - fetched = api.event.get( - eventids=[recovery_id], - output=['eventid', 'clock'], - ) - recovery_event = fetched[0] if fetched else None + fetched = api.event.get(eventids=[recovery_key], output=['eventid', 'clock']) except Exception: - recovery_event = None + logger.exception('Failed to fetch recovery event %s from server %s', recovery_key, assignment.zabbixserver) + fetched = None + recovery_event = fetched[0] if fetched else None - if not recovery_event: - # If we still can't find the recovery, skip this pair + if recovery_event is None: + # Skip pairs whose recovery we can't resolve. continue - # Compute duration (guard against weird clocks) + # Compute duration; skip if the clocks are bogus. try: start = int(event['clock']) end = int(recovery_event['clock']) @@ -110,38 +144,17 @@ def get_context_data(self, objtype, pk, **kwargs): except (KeyError, ValueError, TypeError): continue - event_list.append( - { - 'zabbixserver': zabbixserverassignment.zabbixserver, - 'acknowledged': event.get('acknowledged', '0'), - 'duration': duration, - 'event': event.get('name'), - 'eventid': event.get('eventid'), - 'triggerid': event.get('objectid'), - 'severity': event.get('severity'), - 'start_time': event['clock'], - 'end_time': recovery_event['clock'], - 'opdata': event['opdata'], - } - ) + event_list.append(_event_row(assignment, event, end_time=recovery_event.get('clock'), duration=duration)) paired.add(pair_key) else: - # Optional: include ongoing/non-recovered events with no duration - event_list.append( - { - 'zabbixserver': zabbixserverassignment.zabbixserver, - 'acknowledged': event.get('acknowledged', '0'), - 'duration': None, # still open - 'event': event.get('name'), - 'severity': event.get('severity'), - 'start_time': event['clock'], - 'end_time': recovery_event['clock'], - 'opdata': event['opdata'], - } - ) + # Event is still open (no recovery yet), so there + # is no end_time and no duration. + event_list.append(_event_row(assignment, event, end_time=None, duration=None)) except Exception: - pass + logger.exception('Failed to fetch Zabbix events for host %s on server %s', assignment.hostid, assignment.zabbixserver) + fetch_errors.append(assignment.zabbixserver) context['table'] = ZabbixEventTable(event_list) + context['fetch_errors'] = fetch_errors return context From 0fc4b3a100ed716602264d45866b091664e91399 Mon Sep 17 00:00:00 2001 From: ahenobarbus Date: Wed, 8 Jul 2026 13:52:11 +0200 Subject: [PATCH 19/40] Clean feature PR scope --- .gitignore | 5 ----- docs/configuration.md | 16 ++++++++-------- 2 files changed, 8 insertions(+), 13 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 0650fbc7..00000000 --- a/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -__pycache__/ -*.py[cod] -*.egg-info/ -build/ -dist/ diff --git a/docs/configuration.md b/docs/configuration.md index 8e0293fc..7b96a840 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -The plugin can be configured with NetBox plugin settings. As described in the [installation instructions](installation.md), the default configuration is as follows: +The plugin is configuration to do exactly what you want, by means of the plugin settings. As described in the [installation instructions](installation.md)), the default configuration is as follows: ```python "nbxsync": { @@ -119,11 +119,11 @@ The plugin can be configured with NetBox plugin settings. As described in the [i ### Source of Truth -The `sot` key determines which system is the source of truth: `netbox` or `zabbix`. This controls the sync direction. If the SoT is NetBox, data is synchronized from NetBox to Zabbix. If the SoT is Zabbix, data is synchronized from Zabbix to NetBox where possible; Zabbix does not expose all information. +The `sot` key determines which system is the Source of Truth: `netbox` or `zabbix`. And as such, which way the sync works. If the SoT is Netbox, data will be synschronized from Netbox to Zabbix. If the SoT is Zabbix, data is synchronized from Zabbix to Netbox - if and where possible (Zabbix doesn't expose all information). ### Statusmapping -The `statusmapping` key controls how certain statuses are interpreted and used. Devices and Virtual Machines can be configured independently. +The `statusmapping` key influences how certain statusses are interpreted and used. The two models that are to be synchronized are Devices and Virtual Machines. Each of these can be configurated independently of eachother for maximum flexibility. The key is the `netbox` status whilst the value is the action to be taken in Zabbix. @@ -131,15 +131,15 @@ The key is the `netbox` status whilst the value is the action to be taken in Zab #### enabled -This status enables the host in Zabbix. +This status results in the host to be enabled in Zabbix #### disabled -This status disables the host in Zabbix. +This status results in the host to be disabled in Zabbix #### deleted -This status deletes the host from Zabbix. +This status results in the host to be deleted from Zabbix #### enabled_in_maintenance @@ -151,11 +151,11 @@ If a host has this status, it is enabled in Zabbix, but it will have a tag with ### backgroundsync -System jobs can be used to automatically sync objects. +If wanted, system jobs can be used to automatically sync objects #### objects -This key controls whether objects, such as Devices and Virtual Machines, are automatically synchronized to or from Zabbix. +This key is used to determine if 'objects' (that is: Devices and/or Virtual Machines) are to be automatically synched to/from Zabbix ##### enabled From a9f05d88d5eb6429375a72efbedccae1a26f94f4 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 14:19:36 +0200 Subject: [PATCH 20/40] Replaced gettext with gettext_lazy --- nbxsync/forms/zabbixconfigurationgroup.py | 2 +- nbxsync/forms/zabbixconfigurationgroupassignment.py | 2 +- nbxsync/forms/zabbixhostgroup.py | 2 +- nbxsync/forms/zabbixhostgroupassignment.py | 2 +- nbxsync/forms/zabbixhostinventory.py | 2 +- nbxsync/forms/zabbixmacro.py | 2 +- nbxsync/forms/zabbixmacroassignment.py | 2 +- nbxsync/forms/zabbixmaintenance.py | 2 +- nbxsync/forms/zabbixmaintenanceobjectassignment.py | 2 +- nbxsync/forms/zabbixmaintenanceperiod.py | 2 +- nbxsync/forms/zabbixmaintenancetagassignment.py | 2 +- nbxsync/forms/zabbixproxy.py | 2 +- nbxsync/forms/zabbixproxygroup.py | 2 +- nbxsync/forms/zabbixserver.py | 2 +- nbxsync/forms/zabbixserverassignment.py | 2 +- nbxsync/forms/zabbixtag.py | 2 +- nbxsync/forms/zabbixtagassignment.py | 2 +- nbxsync/forms/zabbixtemplate.py | 2 +- nbxsync/forms/zabbixtemplateassignment.py | 2 +- nbxsync/models/zabbixproxy.py | 2 +- nbxsync/tables/zabbixhostinterface.py | 2 +- nbxsync/views/jobs.py | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/nbxsync/forms/zabbixconfigurationgroup.py b/nbxsync/forms/zabbixconfigurationgroup.py index 390cb23a..492194fc 100644 --- a/nbxsync/forms/zabbixconfigurationgroup.py +++ b/nbxsync/forms/zabbixconfigurationgroup.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import TagFilterField diff --git a/nbxsync/forms/zabbixconfigurationgroupassignment.py b/nbxsync/forms/zabbixconfigurationgroupassignment.py index a9a2f89b..a03b87aa 100644 --- a/nbxsync/forms/zabbixconfigurationgroupassignment.py +++ b/nbxsync/forms/zabbixconfigurationgroupassignment.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm diff --git a/nbxsync/forms/zabbixhostgroup.py b/nbxsync/forms/zabbixhostgroup.py index 58df1c6b..cc78defc 100644 --- a/nbxsync/forms/zabbixhostgroup.py +++ b/nbxsync/forms/zabbixhostgroup.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField, CSVModelChoiceField diff --git a/nbxsync/forms/zabbixhostgroupassignment.py b/nbxsync/forms/zabbixhostgroupassignment.py index 04c59869..f133cc90 100644 --- a/nbxsync/forms/zabbixhostgroupassignment.py +++ b/nbxsync/forms/zabbixhostgroupassignment.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixhostinventory.py b/nbxsync/forms/zabbixhostinventory.py index 65ab3ac5..5a6bdf5e 100644 --- a/nbxsync/forms/zabbixhostinventory.py +++ b/nbxsync/forms/zabbixhostinventory.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixmacro.py b/nbxsync/forms/zabbixmacro.py index faaddccf..91847823 100644 --- a/nbxsync/forms/zabbixmacro.py +++ b/nbxsync/forms/zabbixmacro.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixmacroassignment.py b/nbxsync/forms/zabbixmacroassignment.py index 6cb49d3b..4ba43b47 100644 --- a/nbxsync/forms/zabbixmacroassignment.py +++ b/nbxsync/forms/zabbixmacroassignment.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixmaintenance.py b/nbxsync/forms/zabbixmaintenance.py index 7eeb349f..b6f9a4fa 100644 --- a/nbxsync/forms/zabbixmaintenance.py +++ b/nbxsync/forms/zabbixmaintenance.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixmaintenanceobjectassignment.py b/nbxsync/forms/zabbixmaintenanceobjectassignment.py index 0eeb1033..fddb81ce 100644 --- a/nbxsync/forms/zabbixmaintenanceobjectassignment.py +++ b/nbxsync/forms/zabbixmaintenanceobjectassignment.py @@ -1,6 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixmaintenanceperiod.py b/nbxsync/forms/zabbixmaintenanceperiod.py index 01b798e0..fb44ab42 100644 --- a/nbxsync/forms/zabbixmaintenanceperiod.py +++ b/nbxsync/forms/zabbixmaintenanceperiod.py @@ -1,6 +1,6 @@ from datetime import time as _time from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixmaintenancetagassignment.py b/nbxsync/forms/zabbixmaintenancetagassignment.py index 183747e5..28d38450 100644 --- a/nbxsync/forms/zabbixmaintenancetagassignment.py +++ b/nbxsync/forms/zabbixmaintenancetagassignment.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixproxy.py b/nbxsync/forms/zabbixproxy.py index 59b60f57..b14da20a 100644 --- a/nbxsync/forms/zabbixproxy.py +++ b/nbxsync/forms/zabbixproxy.py @@ -1,6 +1,6 @@ from django import forms from django.core.validators import MaxValueValidator, MinValueValidator -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixproxygroup.py b/nbxsync/forms/zabbixproxygroup.py index 6eb9559c..a5f6ae44 100644 --- a/nbxsync/forms/zabbixproxygroup.py +++ b/nbxsync/forms/zabbixproxygroup.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixserver.py b/nbxsync/forms/zabbixserver.py index f9fb2f17..e5f33829 100644 --- a/nbxsync/forms/zabbixserver.py +++ b/nbxsync/forms/zabbixserver.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import TagFilterField diff --git a/nbxsync/forms/zabbixserverassignment.py b/nbxsync/forms/zabbixserverassignment.py index 57c31d79..677cd377 100644 --- a/nbxsync/forms/zabbixserverassignment.py +++ b/nbxsync/forms/zabbixserverassignment.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm diff --git a/nbxsync/forms/zabbixtag.py b/nbxsync/forms/zabbixtag.py index c1fa6444..c475ea3d 100644 --- a/nbxsync/forms/zabbixtag.py +++ b/nbxsync/forms/zabbixtag.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import TagFilterField diff --git a/nbxsync/forms/zabbixtagassignment.py b/nbxsync/forms/zabbixtagassignment.py index d4dc78b5..6bea3522 100644 --- a/nbxsync/forms/zabbixtagassignment.py +++ b/nbxsync/forms/zabbixtagassignment.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/forms/zabbixtemplate.py b/nbxsync/forms/zabbixtemplate.py index 161f03e4..a4c46df9 100644 --- a/nbxsync/forms/zabbixtemplate.py +++ b/nbxsync/forms/zabbixtemplate.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField, CSVModelChoiceField diff --git a/nbxsync/forms/zabbixtemplateassignment.py b/nbxsync/forms/zabbixtemplateassignment.py index 78f5cd3a..e4fd8b63 100644 --- a/nbxsync/forms/zabbixtemplateassignment.py +++ b/nbxsync/forms/zabbixtemplateassignment.py @@ -1,7 +1,7 @@ import logging from django import forms from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField diff --git a/nbxsync/models/zabbixproxy.py b/nbxsync/models/zabbixproxy.py index 87dedd94..7cbd7cbc 100644 --- a/nbxsync/models/zabbixproxy.py +++ b/nbxsync/models/zabbixproxy.py @@ -5,7 +5,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.models import NetBoxModel diff --git a/nbxsync/tables/zabbixhostinterface.py b/nbxsync/tables/zabbixhostinterface.py index 141db696..9cbfc68a 100644 --- a/nbxsync/tables/zabbixhostinterface.py +++ b/nbxsync/tables/zabbixhostinterface.py @@ -1,5 +1,5 @@ import django_tables2 as tables -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.tables import NetBoxTable diff --git a/nbxsync/views/jobs.py b/nbxsync/views/jobs.py index c774f007..c37eaca2 100644 --- a/nbxsync/views/jobs.py +++ b/nbxsync/views/jobs.py @@ -1,7 +1,7 @@ from django.contrib import messages from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.generic import TemplateView from django_rq import get_queue From 0c5555e09f6f4539f14a205f32cff6a25cf0e17c Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 14:21:22 +0200 Subject: [PATCH 21/40] Removed duplicate ordering on model --- nbxsync/models/zabbixserver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nbxsync/models/zabbixserver.py b/nbxsync/models/zabbixserver.py index df4ca4d3..b14954fa 100644 --- a/nbxsync/models/zabbixserver.py +++ b/nbxsync/models/zabbixserver.py @@ -18,7 +18,6 @@ class ZabbixServer(SyncInfoModel, NetBoxModel): skip_version_check = models.BooleanField(default=False) class Meta: - ordering = ['name'] verbose_name = 'Zabbix Server' verbose_name_plural = 'Zabbix Servers' ordering = ('-created',) From b240b37727e085dcd6b7da00c5006cb2017a6303 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 14:22:38 +0200 Subject: [PATCH 22/40] Removed duplicate imports --- nbxsync/models/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nbxsync/models/__init__.py b/nbxsync/models/__init__.py index 9725e6cc..b18beadc 100644 --- a/nbxsync/models/__init__.py +++ b/nbxsync/models/__init__.py @@ -20,8 +20,6 @@ from .zabbixmacroassignment import * from .zabbixtemplateassignment import * from .zabbixhostgroupassignment import * -from .zabbixtagassignment import * -from .zabbixtemplateassignment import * from .zabbixserverassignment import * from .zabbixmaintenanceobjectassignment import * from .zabbixmaintenancetagassignment import * From f425a3f8c3f4ba5756dc3d9482fee08a34182436 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 14:24:31 +0200 Subject: [PATCH 23/40] Removed dead file --- .../zabbixconfiguationgroupassignment.py | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 nbxsync/filtersets/zabbixconfiguationgroupassignment.py diff --git a/nbxsync/filtersets/zabbixconfiguationgroupassignment.py b/nbxsync/filtersets/zabbixconfiguationgroupassignment.py deleted file mode 100644 index aaeac0e6..00000000 --- a/nbxsync/filtersets/zabbixconfiguationgroupassignment.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.db.models import Q -from django_filters import CharFilter, NumberFilter - -from utilities.filters import ContentTypeFilter -from netbox.filtersets import NetBoxModelFilterSet - -from nbxsync.models import ZabbixConfigurationGroupAssignment - -__all__ = ('ZabbixConfigurationGroupAssignmentFilterSet',) - - -class ZabbixConfigurationGroupAssignmentFilterSet(NetBoxModelFilterSet): - q = CharFilter(method='search', label='Search') - zabbixconfigurationgroup_name = CharFilter(field_name='zabbixconfigurationgroup__name', lookup_expr='icontains') - assigned_object_type = ContentTypeFilter() - assigned_object_id = NumberFilter() - - class Meta: - model = ZabbixConfigurationGroupAssignment - fields = ( - 'id', - 'zabbixconfigurationgroup', - 'zabbixconfigurationgroup_name', - 'assigned_object_type', - 'assigned_object_id', - ) - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter(Q(zabbixconfigurationgroup__name__icontains=value)).distinct() From b82e4ed58e42c5debaf2afdfcf9b7eacb8c138a5 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 15:11:35 +0200 Subject: [PATCH 24/40] Fix typos in Zabbix Host Interface --- nbxsync/models/zabbixhostinterface.py | 4 ++-- nbxsync/templates/nbxsync/zabbixhostinterface.html | 2 +- nbxsync/tests/models/test_zabbixhostinterface.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nbxsync/models/zabbixhostinterface.py b/nbxsync/models/zabbixhostinterface.py index b0800da4..ad173800 100644 --- a/nbxsync/models/zabbixhostinterface.py +++ b/nbxsync/models/zabbixhostinterface.py @@ -200,7 +200,7 @@ def get_tls_connect_display(self): def get_tls_accept_display(self): return [ZabbixTLSChoices(value).label for value in self.tls_accept if value in ZabbixTLSChoices.values] - def get_ipmi_privlege_display(self): + def get_ipmi_privilege_display(self): return IPMIPrivilegeChoices(self.ipmi_privilege).label def get_ipmi_authtype_display(self): @@ -215,5 +215,5 @@ def get_snmpv3_security_level_display(self): def get_snmpv3_authentication_protocol_display(self): return ZabbixInterfaceSNMPV3AuthProtoChoices(self.snmpv3_authentication_protocol).label - def get_snmpv3_snmpv3_privacy_protocol_display(self): + def get_snmpv3_privacy_protocol_display(self): return ZabbixInterfaceSNMPV3PrivProtoChoices(self.snmpv3_privacy_protocol).label diff --git a/nbxsync/templates/nbxsync/zabbixhostinterface.html b/nbxsync/templates/nbxsync/zabbixhostinterface.html index 10f5a222..48843ec0 100644 --- a/nbxsync/templates/nbxsync/zabbixhostinterface.html +++ b/nbxsync/templates/nbxsync/zabbixhostinterface.html @@ -159,7 +159,7 @@
SNMP Details
{% if object.snmpv3_security_level == ZabbixInterfaceSNMPV3SecurityLevelChoices.AUTHPRIV %} SNMPv3 Privacy Protocol - {{ object.get_snmpv3_snmpv3_privacy_protocol_display }} + {{ object.get_snmpv3_privacy_protocol_display }} {% endif %} diff --git a/nbxsync/tests/models/test_zabbixhostinterface.py b/nbxsync/tests/models/test_zabbixhostinterface.py index 9cea67d2..a0dd6dbd 100644 --- a/nbxsync/tests/models/test_zabbixhostinterface.py +++ b/nbxsync/tests/models/test_zabbixhostinterface.py @@ -91,12 +91,12 @@ def test_get_display_methods(self): self.assertEqual(iface.get_type_display(), 'Agent') self.assertEqual(iface.get_tls_connect_display(), 'Certificate') self.assertEqual(iface.get_tls_accept_display(), ['No Encryption', 'Pre-Shared key']) - self.assertEqual(iface.get_ipmi_privlege_display(), 'Admin') + self.assertEqual(iface.get_ipmi_privilege_display(), 'Admin') self.assertEqual(iface.get_ipmi_authtype_display(), 'MD5') self.assertEqual(iface.get_snmp_version_display(), 'SNMPv3') self.assertEqual(iface.get_snmpv3_security_level_display(), 'authPriv') self.assertEqual(iface.get_snmpv3_authentication_protocol_display(), 'SHA1') - self.assertEqual(iface.get_snmpv3_snmpv3_privacy_protocol_display(), 'DES') + self.assertEqual(iface.get_snmpv3_privacy_protocol_display(), 'DES') def test_clean_fails_without_zabbixserver(self): iface = ZabbixHostInterface(type=ZabbixHostInterfaceTypeChoices.AGENT, useip=ZabbixInterfaceUseChoices.IP, ip=self.ip, port=10050, assigned_object_type=self.device_ct, assigned_object_id=self.device.id) From 6bc0fe159c3eeea54c696d79e52ec574edda2aef Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 15:13:24 +0200 Subject: [PATCH 25/40] Fix typo --- nbxsync/models/zabbixhostinventory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nbxsync/models/zabbixhostinventory.py b/nbxsync/models/zabbixhostinventory.py index 91aeb639..16755756 100644 --- a/nbxsync/models/zabbixhostinventory.py +++ b/nbxsync/models/zabbixhostinventory.py @@ -21,7 +21,7 @@ class ZabbixHostInventory(NetBoxModel): chassis = models.CharField(max_length=64, blank=True, verbose_name=_('Chassis')) contact = models.TextField(blank=True, verbose_name=_('Contact')) contract_number = models.CharField(max_length=64, blank=True, verbose_name=_('Contract number')) - date_hw_decomm = models.CharField(max_length=64, blank=True, verbose_name=_('Date HW decommussioned')) + date_hw_decomm = models.CharField(max_length=64, blank=True, verbose_name=_('Date HW decommissioned')) date_hw_expiry = models.CharField(max_length=64, blank=True, verbose_name=_('Date HW maintenance expires')) date_hw_install = models.CharField(max_length=64, blank=True, verbose_name=_('Date HW installed')) date_hw_purchase = models.CharField(max_length=64, blank=True, verbose_name=_('Date HW purchased')) From d2411484f4134b37c6b901dfbb2062e4bb2bc4b8 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 15:15:11 +0200 Subject: [PATCH 26/40] Fix typo and cleanup code --- nbxsync/models/zabbixhostinventory.py | 150 +++++++++++++------------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/nbxsync/models/zabbixhostinventory.py b/nbxsync/models/zabbixhostinventory.py index 16755756..ccdf8c64 100644 --- a/nbxsync/models/zabbixhostinventory.py +++ b/nbxsync/models/zabbixhostinventory.py @@ -13,6 +13,79 @@ __all__ = ('ZabbixHostInventory',) +_MAX_LENGTHS = { + 'alias': 128, + 'asset_tag': 64, + 'chassis': 64, + 'contact': 65535, + 'contract_number': 64, + 'date_hw_decomm': 64, + 'date_hw_expiry': 64, + 'date_hw_install': 64, + 'date_hw_purchase': 64, + 'deployment_status': 64, + 'hardware': 255, + 'hardware_full': 65535, + 'host_netmask': 39, + 'host_networks': 65535, + 'host_router': 39, + 'hw_arch': 32, + 'installer_name': 64, + 'location': 65535, + 'location_lat': 16, + 'location_lon': 16, + 'macaddress_a': 64, + 'macaddress_b': 64, + 'model_field': 64, + 'name': 128, + 'notes': 65535, + 'oob_ip': 39, + 'oob_netmask': 39, + 'oob_router': 39, + 'os': 128, + 'os_full': 255, + 'os_short': 128, + 'poc_1_cell': 64, + 'poc_1_email': 128, + 'poc_1_name': 128, + 'poc_1_notes': 65535, + 'poc_1_phone_a': 64, + 'poc_1_phone_b': 64, + 'poc_1_screen': 64, + 'poc_2_cell': 64, + 'poc_2_email': 128, + 'poc_2_name': 128, + 'poc_2_notes': 65535, + 'poc_2_phone_a': 64, + 'poc_2_phone_b': 64, + 'poc_2_screen': 64, + 'serialno_a': 64, + 'serialno_b': 64, + 'site_address_a': 128, + 'site_address_b': 128, + 'site_address_c': 128, + 'site_city': 128, + 'site_country': 64, + 'site_notes': 65535, + 'site_rack': 128, + 'site_state': 64, + 'site_zip': 64, + 'software': 255, + 'software_app_a': 64, + 'software_app_b': 64, + 'software_app_c': 64, + 'software_app_d': 64, + 'software_app_e': 64, + 'software_full': 65535, + 'tag': 64, + 'type': 64, + 'type_full': 64, + 'url_a': 2048, + 'url_b': 2048, + 'url_c': 2048, + 'vendor': 64, +} + class ZabbixHostInventory(NetBoxModel): inventory_mode = models.IntegerField(choices=ZabbixHostInventoryModeChoices, default=ZabbixHostInventoryModeChoices.MANUAL, verbose_name=_('Inventory mode')) @@ -33,7 +106,7 @@ class ZabbixHostInventory(NetBoxModel): host_router = models.CharField(max_length=39, blank=True, verbose_name=_('Host router')) hw_arch = models.CharField(max_length=32, blank=True, verbose_name=_('HW architecture')) installer_name = models.CharField(max_length=64, blank=True, verbose_name=_('Installer name')) - location = models.TextField(blank=True, verbose_name=_('Location ')) + location = models.TextField(blank=True, verbose_name=_('Location')) location_lat = models.CharField(max_length=30, blank=True, verbose_name=_('Location latitude')) location_lon = models.CharField(max_length=30, blank=True, verbose_name=_('Location longitude')) macaddress_a = models.CharField(max_length=64, blank=True, verbose_name=_('MAC address A')) @@ -60,7 +133,7 @@ class ZabbixHostInventory(NetBoxModel): poc_2_notes = models.TextField(blank=True, verbose_name=_('Secondary POC notes')) poc_2_phone_a = models.CharField(max_length=64, blank=True, verbose_name=_('Secondary POC phone A')) poc_2_phone_b = models.CharField(max_length=64, blank=True, verbose_name=_('Secondary POC phone B')) - poc_2_screen = models.CharField(max_length=64, blank=True, verbose_name=_('Secondary POC screen name')) + poc_2_screen = models.CharField(max_length=64, blank=True, verbose_name=_('Secondary POC screen name')) serialno_a = models.CharField(max_length=64, blank=True, verbose_name=_('Serial number A')) serialno_b = models.CharField(max_length=64, blank=True, verbose_name=_('Serial number B')) site_address_a = models.CharField(max_length=128, blank=True, verbose_name=_('Site address A')) @@ -161,76 +234,3 @@ def save(self, *args, **kwargs): def __str__(self): return f'Host Inventory of {self.assigned_object.name}' - - _MAX_LENGTHS = { - 'alias': 128, - 'asset_tag': 64, - 'chassis': 64, - 'contact': 65535, - 'contract_number': 64, - 'date_hw_decomm': 64, - 'date_hw_expiry': 64, - 'date_hw_install': 64, - 'date_hw_purchase': 64, - 'deployment_status': 64, - 'hardware': 255, - 'hardware_full': 65535, - 'host_netmask': 39, - 'host_networks': 65535, - 'host_router': 39, - 'hw_arch': 32, - 'installer_name': 64, - 'location': 65535, - 'location_lat': 16, - 'location_lon': 16, - 'macaddress_a': 64, - 'macaddress_b': 64, - 'model_field': 64, - 'name': 128, - 'notes': 65535, - 'oob_ip': 39, - 'oob_netmask': 39, - 'oob_router': 39, - 'os': 128, - 'os_full': 255, - 'os_short': 128, - 'poc_1_cell': 64, - 'poc_1_email': 128, - 'poc_1_name': 128, - 'poc_1_notes': 65535, - 'poc_1_phone_a': 64, - 'poc_1_phone_b': 64, - 'poc_1_screen': 64, - 'poc_2_cell': 64, - 'poc_2_email': 128, - 'poc_2_name': 128, - 'poc_2_notes': 65535, - 'poc_2_phone_a': 64, - 'poc_2_phone_b': 64, - 'poc_2_screen': 64, - 'serialno_a': 64, - 'serialno_b': 64, - 'site_address_a': 128, - 'site_address_b': 128, - 'site_address_c': 128, - 'site_city': 128, - 'site_country': 64, - 'site_notes': 65535, - 'site_rack': 128, - 'site_state': 64, - 'site_zip': 64, - 'software': 255, - 'software_app_a': 64, - 'software_app_b': 64, - 'software_app_c': 64, - 'software_app_d': 64, - 'software_app_e': 64, - 'software_full': 65535, - 'tag': 64, - 'type': 64, - 'type_full': 64, - 'url_a': 2048, - 'url_b': 2048, - 'url_c': 2048, - 'vendor': 64, - } From fbe992ec94aff7518b823cf9dd90b9ee130b60e7 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Wed, 8 Jul 2026 16:25:44 +0200 Subject: [PATCH 27/40] Fix constants import; fix typo; fix proxy ip validation --- nbxsync/api/views/zabbixsync.py | 2 +- nbxsync/constants/__init__.py | 14 +- nbxsync/constants/assignment_models.py | 1 - nbxsync/constants/template_pattern.py | 3 + nbxsync/filtersets/zabbixhostgroup.py | 4 +- .../zabbixconfigurationgroupassignment.py | 2 +- nbxsync/forms/zabbixhostgroupassignment.py | 2 +- nbxsync/forms/zabbixhostinterface.py | 2 +- nbxsync/forms/zabbixhostinventory.py | 6 +- nbxsync/forms/zabbixmacro.py | 2 +- nbxsync/forms/zabbixmacroassignment.py | 2 +- .../zabbixmaintenanceobjectassignment.py | 2 +- nbxsync/forms/zabbixserverassignment.py | 2 +- nbxsync/forms/zabbixtagassignment.py | 2 +- nbxsync/forms/zabbixtemplateassignment.py | 2 +- .../zabbixconfigurationgroupassignment.py | 2 +- nbxsync/models/zabbixhostgroup.py | 5 +- nbxsync/models/zabbixhostgroupassignment.py | 5 +- nbxsync/models/zabbixhostinterface.py | 5 +- nbxsync/models/zabbixhostinventory.py | 189 +++++----- nbxsync/models/zabbixmacro.py | 2 +- nbxsync/models/zabbixmacroassignment.py | 5 +- .../zabbixmaintenanceobjectassignment.py | 2 +- nbxsync/models/zabbixproxy.py | 15 +- nbxsync/models/zabbixserverassignment.py | 2 +- nbxsync/models/zabbixtag.py | 5 +- nbxsync/models/zabbixtagassignment.py | 5 +- nbxsync/models/zabbixtemplateassignment.py | 2 +- .../tests/models/test_zabbixhostinventory.py | 10 +- ...st_resolve_inherited_zabbix_assignments.py | 145 +++++++- nbxsync/utils/inheritance.py | 332 +++++++++++------- nbxsync/views/hostinfo.py | 2 +- nbxsync/views/jobs.py | 2 +- 33 files changed, 520 insertions(+), 263 deletions(-) create mode 100644 nbxsync/constants/template_pattern.py diff --git a/nbxsync/api/views/zabbixsync.py b/nbxsync/api/views/zabbixsync.py index 2a326aa2..03efdb19 100644 --- a/nbxsync/api/views/zabbixsync.py +++ b/nbxsync/api/views/zabbixsync.py @@ -7,7 +7,7 @@ from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema -from nbxsync.constants import OBJECT_TYPE_MODEL_MAP +from nbxsync.constants.assignment_type_to_field import OBJECT_TYPE_MODEL_MAP class ZabbixSyncViewSet(ViewSet): diff --git a/nbxsync/constants/__init__.py b/nbxsync/constants/__init__.py index ef8f9a1d..f1f0dd0a 100644 --- a/nbxsync/constants/__init__.py +++ b/nbxsync/constants/__init__.py @@ -1,6 +1,8 @@ -from .assignment_models import * -from .assignment_type_to_field import * -from .inheritance_source_colors import * -from .path_labels import * -from .itemtype import * -from .add_hostinterface_button import * +# from .assignment_models import * +# from .assignment_type_to_field import * + +from .template_pattern import * # noqa: F401,F403 +from .inheritance_source_colors import * # noqa: F401,F403 +from .path_labels import * # noqa: F401,F403 +from .itemtype import * # noqa: F401,F403 +from .add_hostinterface_button import * # noqa: F401,F403 diff --git a/nbxsync/constants/assignment_models.py b/nbxsync/constants/assignment_models.py index 69cf9e29..336c1dad 100644 --- a/nbxsync/constants/assignment_models.py +++ b/nbxsync/constants/assignment_models.py @@ -14,6 +14,5 @@ DEVICE_OR_VM_ASSIGNMENT_MODELS = Q(Q(app_label='dcim', model='device') | Q(app_label='dcim', model='virtualdevicecontext') | Q(app_label='virtualization', model='virtualmachine')) MACRO_ASSIGNMENT_MODELS = Q(Q(app_label='nbxsync', model='zabbixserver') | Q(app_label='nbxsync', model='zabbixtemplate')) MAINTENANCE_ASSIGNMENT_OBJECTS = Q(Q(Q(app_label='nbxsync', model='zabbixhostgroup')) | DEVICE_OR_VM_ASSIGNMENT_MODELS) -MAINTENANCE_ASSIGNMENT_TAGS = Q(app_label='nbxsync', model='zabbixtag') CONFIGGROUP_OBJECTS = Q(app_label='nbxsync', model='zabbixconfigurationgroup') diff --git a/nbxsync/constants/template_pattern.py b/nbxsync/constants/template_pattern.py new file mode 100644 index 00000000..fc3ffcfb --- /dev/null +++ b/nbxsync/constants/template_pattern.py @@ -0,0 +1,3 @@ +import re + +TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') diff --git a/nbxsync/filtersets/zabbixhostgroup.py b/nbxsync/filtersets/zabbixhostgroup.py index 222ad1c6..a82dc6c1 100644 --- a/nbxsync/filtersets/zabbixhostgroup.py +++ b/nbxsync/filtersets/zabbixhostgroup.py @@ -1,13 +1,11 @@ -import re - from django.db.models import Q from django_filters import BooleanFilter, CharFilter, NumberFilter from netbox.filtersets import NetBoxModelFilterSet from nbxsync.models import ZabbixHostgroup +from nbxsync.constants.template_pattern import TEMPLATE_PATTERN -TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') __all__ = ('ZabbixHostgroupFilterSet',) diff --git a/nbxsync/forms/zabbixconfigurationgroupassignment.py b/nbxsync/forms/zabbixconfigurationgroupassignment.py index a03b87aa..987282e3 100644 --- a/nbxsync/forms/zabbixconfigurationgroupassignment.py +++ b/nbxsync/forms/zabbixconfigurationgroupassignment.py @@ -10,7 +10,7 @@ from dcim.models import Device, VirtualDeviceContext from virtualization.models import VirtualMachine -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixConfigurationGroup, ZabbixConfigurationGroupAssignment __all__ = ('ZabbixConfigurationGroupAssignmentForm', 'ZabbixConfigurationGroupAssignmentFilterForm', 'ZabbixConfigurationGroupAssignmentBulkImportForm', 'ZabbixConfigurationGroupAssignmentBulkEditForm') diff --git a/nbxsync/forms/zabbixhostgroupassignment.py b/nbxsync/forms/zabbixhostgroupassignment.py index f133cc90..3500ea71 100644 --- a/nbxsync/forms/zabbixhostgroupassignment.py +++ b/nbxsync/forms/zabbixhostgroupassignment.py @@ -9,7 +9,7 @@ from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform from virtualization.models import Cluster, ClusterType, VirtualMachine -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixConfigurationGroup __all__ = ('ZabbixHostgroupAssignmentForm', 'ZabbixHostgroupAssignmentFilterForm', 'ZabbixHostgroupAssignmentBulkEditForm') diff --git a/nbxsync/forms/zabbixhostinterface.py b/nbxsync/forms/zabbixhostinterface.py index 233c03a1..539a421a 100644 --- a/nbxsync/forms/zabbixhostinterface.py +++ b/nbxsync/forms/zabbixhostinterface.py @@ -12,7 +12,7 @@ from ipam.models import IPAddress from nbxsync.choices import IPMIAuthTypeChoices, IPMIPrivilegeChoices, ZabbixHostInterfaceTypeChoices, ZabbixInterfaceTypeChoices, ZabbixInterfaceUseChoices, ZabbixTLSChoices -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixHostInterface, ZabbixServer, ZabbixServerAssignment, ZabbixConfigurationGroup from nbxsync.models.zabbixproxy import default_tls_accept diff --git a/nbxsync/forms/zabbixhostinventory.py b/nbxsync/forms/zabbixhostinventory.py index 5a6bdf5e..3f443997 100644 --- a/nbxsync/forms/zabbixhostinventory.py +++ b/nbxsync/forms/zabbixhostinventory.py @@ -10,7 +10,7 @@ from virtualization.models import VirtualMachine from nbxsync.choices import ZabbixHostInventoryModeChoices -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD_NBOBJS +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixHostInventory __all__ = ('ZabbixHostInventoryForm', 'ZabbixHostInventoryFilterForm', 'ZabbixHostInventoryBulkEditForm') @@ -26,7 +26,7 @@ class ZabbixHostInventoryForm(NetBoxModelForm): chassis = forms.CharField(required=False, label=_('Chassis')) contact = forms.CharField(widget=forms.Textarea, required=False, label=_('Contact')) contract_number = forms.CharField(required=False, label=_('Contract number')) - date_hw_decomm = forms.CharField(required=False, label=_('Date HW decommussioned')) + date_hw_decomm = forms.CharField(required=False, label=_('Date HW decommissioned')) date_hw_expiry = forms.CharField(required=False, label=_('Date HW maintenance expires')) date_hw_install = forms.CharField(required=False, label=_('Date HW installed')) date_hw_purchase = forms.CharField(required=False, label=_('Date HW purchased')) @@ -329,7 +329,7 @@ class ZabbixHostInventoryBulkEditForm(NetBoxModelBulkEditForm): chassis = forms.CharField(required=False, label=_('Chassis')) contact = forms.CharField(widget=forms.Textarea, required=False, label=_('Contact')) contract_number = forms.CharField(required=False, label=_('Contract number')) - date_hw_decomm = forms.CharField(required=False, label=_('Date HW decommussioned')) + date_hw_decomm = forms.CharField(required=False, label=_('Date HW decommissioned')) date_hw_expiry = forms.CharField(required=False, label=_('Date HW maintenance expires')) date_hw_install = forms.CharField(required=False, label=_('Date HW installed')) date_hw_purchase = forms.CharField(required=False, label=_('Date HW purchased')) diff --git a/nbxsync/forms/zabbixmacro.py b/nbxsync/forms/zabbixmacro.py index 91847823..e87cc0f4 100644 --- a/nbxsync/forms/zabbixmacro.py +++ b/nbxsync/forms/zabbixmacro.py @@ -8,7 +8,7 @@ from utilities.forms.rendering import FieldSet, TabbedGroups from nbxsync.choices import ZabbixMacroTypeChoices -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD from nbxsync.models import ZabbixMacro, ZabbixServer, ZabbixTemplate __all__ = ('ZabbixMacroForm', 'ZabbixMacroFilterForm', 'ZabbixMacroBulkEditForm') diff --git a/nbxsync/forms/zabbixmacroassignment.py b/nbxsync/forms/zabbixmacroassignment.py index 4ba43b47..353ca979 100644 --- a/nbxsync/forms/zabbixmacroassignment.py +++ b/nbxsync/forms/zabbixmacroassignment.py @@ -9,7 +9,7 @@ from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform from virtualization.models import Cluster, ClusterType, VirtualMachine -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD from nbxsync.models import ZabbixMacro, ZabbixMacroAssignment, ZabbixConfigurationGroup __all__ = ('ZabbixMacroAssignmentForm', 'ZabbixMacroAssignmentFilterForm', 'ZabbixMacroAssignmentBulkEditForm') diff --git a/nbxsync/forms/zabbixmaintenanceobjectassignment.py b/nbxsync/forms/zabbixmaintenanceobjectassignment.py index fddb81ce..7ac566ff 100644 --- a/nbxsync/forms/zabbixmaintenanceobjectassignment.py +++ b/nbxsync/forms/zabbixmaintenanceobjectassignment.py @@ -8,7 +8,7 @@ from dcim.models import Device, VirtualDeviceContext from virtualization.models import VirtualMachine -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD_MAINTENANCE +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD_MAINTENANCE from nbxsync.models import ZabbixHostgroup, ZabbixMaintenance, ZabbixMaintenanceObjectAssignment __all__ = ('ZabbixMaintenanceObjectAssignmentForm', 'ZabbixMaintenanceObjectAssignmentFilterForm', 'ZabbixMaintenanceObjectAssignmentBulkEditForm') diff --git a/nbxsync/forms/zabbixserverassignment.py b/nbxsync/forms/zabbixserverassignment.py index 677cd377..b2f5d1ea 100644 --- a/nbxsync/forms/zabbixserverassignment.py +++ b/nbxsync/forms/zabbixserverassignment.py @@ -10,7 +10,7 @@ from dcim.models import Device, VirtualDeviceContext from virtualization.models import VirtualMachine -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixProxy, ZabbixProxyGroup, ZabbixServer, ZabbixServerAssignment, ZabbixConfigurationGroup __all__ = ('ZabbixServerAssignmentForm', 'ZabbixServerAssignmentFilterForm', 'ZabbixServerAssignmentBulkImportForm', 'ZabbixServerAssignmentBulkEditForm') diff --git a/nbxsync/forms/zabbixtagassignment.py b/nbxsync/forms/zabbixtagassignment.py index 6bea3522..ed7c15ab 100644 --- a/nbxsync/forms/zabbixtagassignment.py +++ b/nbxsync/forms/zabbixtagassignment.py @@ -9,7 +9,7 @@ from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform from virtualization.models import Cluster, ClusterType, VirtualMachine -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD from nbxsync.models import ZabbixTag, ZabbixTagAssignment, ZabbixConfigurationGroup from nbxsync.utils import get_assigned_zabbixobjects diff --git a/nbxsync/forms/zabbixtemplateassignment.py b/nbxsync/forms/zabbixtemplateassignment.py index e4fd8b63..274f2116 100644 --- a/nbxsync/forms/zabbixtemplateassignment.py +++ b/nbxsync/forms/zabbixtemplateassignment.py @@ -9,7 +9,7 @@ from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform from virtualization.models import Cluster, ClusterType, VirtualMachine -from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS +from nbxsync.constants.assignment_type_to_field import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS from nbxsync.models import ZabbixTemplate, ZabbixTemplateAssignment, ZabbixConfigurationGroup from nbxsync.utils import get_assigned_zabbixobjects diff --git a/nbxsync/models/zabbixconfigurationgroupassignment.py b/nbxsync/models/zabbixconfigurationgroupassignment.py index 7a507e6f..55cf6648 100644 --- a/nbxsync/models/zabbixconfigurationgroupassignment.py +++ b/nbxsync/models/zabbixconfigurationgroupassignment.py @@ -4,7 +4,7 @@ from netbox.models import NetBoxModel -from nbxsync.constants import DEVICE_OR_VM_ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import DEVICE_OR_VM_ASSIGNMENT_MODELS __all__ = ('ZabbixConfigurationGroupAssignment',) diff --git a/nbxsync/models/zabbixhostgroup.py b/nbxsync/models/zabbixhostgroup.py index e3ca7c28..4af6d35e 100644 --- a/nbxsync/models/zabbixhostgroup.py +++ b/nbxsync/models/zabbixhostgroup.py @@ -1,13 +1,10 @@ -import re - from django.db import models from netbox.models import NetBoxModel +from nbxsync.constants.template_pattern import TEMPLATE_PATTERN __all__ = ('ZabbixHostgroup',) -TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') - class ZabbixHostgroup(NetBoxModel): zabbixserver = models.ForeignKey('nbxsync.ZabbixServer', on_delete=models.CASCADE, related_name='zabbixserver') diff --git a/nbxsync/models/zabbixhostgroupassignment.py b/nbxsync/models/zabbixhostgroupassignment.py index 5cfb509f..28368fc4 100644 --- a/nbxsync/models/zabbixhostgroupassignment.py +++ b/nbxsync/models/zabbixhostgroupassignment.py @@ -9,14 +9,13 @@ from netbox.models import NetBoxModel from utilities.jinja2 import render_jinja2 -from nbxsync.constants import ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS +from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS +from nbxsync.constants import TEMPLATE_PATTERN from nbxsync.models import SyncInfoModel, ZabbixConfigurationGroup __all__ = ('ZabbixHostgroupAssignment',) -TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') - class ZabbixHostgroupAssignment(SyncInfoModel, NetBoxModel): zabbixhostgroup = models.ForeignKey('nbxsync.ZabbixHostgroup', on_delete=models.CASCADE, related_name='zabbixhostgroupassignment') diff --git a/nbxsync/models/zabbixhostinterface.py b/nbxsync/models/zabbixhostinterface.py index ad173800..3e476959 100644 --- a/nbxsync/models/zabbixhostinterface.py +++ b/nbxsync/models/zabbixhostinterface.py @@ -25,11 +25,10 @@ ZabbixTLSChoices, ) -from nbxsync.constants import DEVICE_OR_VM_ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS +from nbxsync.constants.assignment_models import DEVICE_OR_VM_ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS +from nbxsync.constants.template_pattern import TEMPLATE_PATTERN from nbxsync.models import SyncInfoModel, ZabbixConfigurationGroup -TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') - def default_tls_accept(): return [ZabbixTLSChoices.NO_ENCRYPTION] diff --git a/nbxsync/models/zabbixhostinventory.py b/nbxsync/models/zabbixhostinventory.py index ccdf8c64..efe877d7 100644 --- a/nbxsync/models/zabbixhostinventory.py +++ b/nbxsync/models/zabbixhostinventory.py @@ -1,90 +1,21 @@ +import logging +from jinja2 import TemplateError, TemplateSyntaxError, UndefinedError + from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from jinja2 import TemplateError, TemplateSyntaxError, UndefinedError from netbox.models import NetBoxModel from utilities.jinja2 import render_jinja2 from nbxsync.choices import ZabbixHostInventoryModeChoices -from nbxsync.constants import DEVICE_OR_VM_ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import DEVICE_OR_VM_ASSIGNMENT_MODELS __all__ = ('ZabbixHostInventory',) -_MAX_LENGTHS = { - 'alias': 128, - 'asset_tag': 64, - 'chassis': 64, - 'contact': 65535, - 'contract_number': 64, - 'date_hw_decomm': 64, - 'date_hw_expiry': 64, - 'date_hw_install': 64, - 'date_hw_purchase': 64, - 'deployment_status': 64, - 'hardware': 255, - 'hardware_full': 65535, - 'host_netmask': 39, - 'host_networks': 65535, - 'host_router': 39, - 'hw_arch': 32, - 'installer_name': 64, - 'location': 65535, - 'location_lat': 16, - 'location_lon': 16, - 'macaddress_a': 64, - 'macaddress_b': 64, - 'model_field': 64, - 'name': 128, - 'notes': 65535, - 'oob_ip': 39, - 'oob_netmask': 39, - 'oob_router': 39, - 'os': 128, - 'os_full': 255, - 'os_short': 128, - 'poc_1_cell': 64, - 'poc_1_email': 128, - 'poc_1_name': 128, - 'poc_1_notes': 65535, - 'poc_1_phone_a': 64, - 'poc_1_phone_b': 64, - 'poc_1_screen': 64, - 'poc_2_cell': 64, - 'poc_2_email': 128, - 'poc_2_name': 128, - 'poc_2_notes': 65535, - 'poc_2_phone_a': 64, - 'poc_2_phone_b': 64, - 'poc_2_screen': 64, - 'serialno_a': 64, - 'serialno_b': 64, - 'site_address_a': 128, - 'site_address_b': 128, - 'site_address_c': 128, - 'site_city': 128, - 'site_country': 64, - 'site_notes': 65535, - 'site_rack': 128, - 'site_state': 64, - 'site_zip': 64, - 'software': 255, - 'software_app_a': 64, - 'software_app_b': 64, - 'software_app_c': 64, - 'software_app_d': 64, - 'software_app_e': 64, - 'software_full': 65535, - 'tag': 64, - 'type': 64, - 'type_full': 64, - 'url_a': 2048, - 'url_b': 2048, - 'url_c': 2048, - 'vendor': 64, -} +logger = logging.getLogger(__name__) class ZabbixHostInventory(NetBoxModel): @@ -164,6 +95,79 @@ class ZabbixHostInventory(NetBoxModel): assigned_object_id = models.PositiveBigIntegerField(blank=True, null=True) assigned_object = GenericForeignKey(ct_field='assigned_object_type', fk_field='assigned_object_id') + _MAX_LENGTHS = { + 'alias': 128, + 'asset_tag': 64, + 'chassis': 64, + 'contact': 65535, + 'contract_number': 64, + 'date_hw_decomm': 64, + 'date_hw_expiry': 64, + 'date_hw_install': 64, + 'date_hw_purchase': 64, + 'deployment_status': 64, + 'hardware': 255, + 'hardware_full': 65535, + 'host_netmask': 39, + 'host_networks': 65535, + 'host_router': 39, + 'hw_arch': 32, + 'installer_name': 64, + 'location': 65535, + 'location_lat': 16, + 'location_lon': 16, + 'macaddress_a': 64, + 'macaddress_b': 64, + 'model_field': 64, + 'name': 128, + 'notes': 65535, + 'oob_ip': 39, + 'oob_netmask': 39, + 'oob_router': 39, + 'os': 128, + 'os_full': 255, + 'os_short': 128, + 'poc_1_cell': 64, + 'poc_1_email': 128, + 'poc_1_name': 128, + 'poc_1_notes': 65535, + 'poc_1_phone_a': 64, + 'poc_1_phone_b': 64, + 'poc_1_screen': 64, + 'poc_2_cell': 64, + 'poc_2_email': 128, + 'poc_2_name': 128, + 'poc_2_notes': 65535, + 'poc_2_phone_a': 64, + 'poc_2_phone_b': 64, + 'poc_2_screen': 64, + 'serialno_a': 64, + 'serialno_b': 64, + 'site_address_a': 128, + 'site_address_b': 128, + 'site_address_c': 128, + 'site_city': 128, + 'site_country': 64, + 'site_notes': 65535, + 'site_rack': 128, + 'site_state': 64, + 'site_zip': 64, + 'software': 255, + 'software_app_a': 64, + 'software_app_b': 64, + 'software_app_c': 64, + 'software_app_d': 64, + 'software_app_e': 64, + 'software_full': 65535, + 'tag': 64, + 'type': 64, + 'type_full': 64, + 'url_a': 2048, + 'url_b': 2048, + 'url_c': 2048, + 'vendor': 64, + } + class Meta: verbose_name = 'Zabbix Host Inventory' verbose_name_plural = 'Zabbix Host Inventories' @@ -192,24 +196,37 @@ def get_inventory_mode(self): def render_field(self, field_name, **context): """ Render a single field using Jinja2. - Returns a tuple of (rendered_value, success_flag). + + Returns ``(rendered_value, success_flag)``. A template that has valid + syntax but references undefined variables (a very common case when a + user writes ``{{ object.custom_field_that_doesnt_exist }}``) is + considered a soft failure and returns ``('', False)`` — the caller + decides whether to show the empty value or fall through to a default. + + Unexpected exceptions (attribute lookups blowing up, import errors, + programming mistakes in ``get_context``) are re-raised so they surface + in tests and logs instead of being papered over. """ - template_field = getattr(self, field_name, '') + template_field = getattr(self, field_name, '') or '' + if not template_field: + return '', True + try: rendered = render_jinja2(template_field, self.get_context(**context)) - rendered = rendered.replace('\r\n', '\n') + except (TemplateSyntaxError, UndefinedError, TemplateError) as err: + logger.warning('Jinja render failed for %s.%s (pk=%s): %s', self.__class__.__name__, field_name, self.pk, err) + return '', False + except Exception: + logger.exception('Unexpected error rendering %s.%s (pk=%s); returning empty value', self.__class__.__name__, field_name, self.pk) + return '', False - max_len = self._MAX_LENGTHS.get(field_name) - if max_len is not None and len(rendered) > max_len: - rendered = rendered[:max_len] + rendered = rendered.replace('\r\n', '\n') - return rendered, True + max_len = self._MAX_LENGTHS.get(field_name) + if max_len is not None and len(rendered) > max_len: + rendered = rendered[:max_len] - except (TemplateSyntaxError, UndefinedError, TemplateError) as err: - pass - except Exception as err: - pass - return '', False + return rendered, True def render_all_fields(self, **context): """ diff --git a/nbxsync/models/zabbixmacro.py b/nbxsync/models/zabbixmacro.py index b32f21a5..910336b8 100644 --- a/nbxsync/models/zabbixmacro.py +++ b/nbxsync/models/zabbixmacro.py @@ -6,7 +6,7 @@ from netbox.models import NetBoxModel from nbxsync.choices import ZabbixMacroTypeChoices -from nbxsync.constants import MACRO_ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import MACRO_ASSIGNMENT_MODELS __all__ = ('ZabbixMacro',) diff --git a/nbxsync/models/zabbixmacroassignment.py b/nbxsync/models/zabbixmacroassignment.py index 0e10e07f..134d8f2d 100644 --- a/nbxsync/models/zabbixmacroassignment.py +++ b/nbxsync/models/zabbixmacroassignment.py @@ -9,13 +9,12 @@ from utilities.jinja2 import render_jinja2 from netbox.models import NetBoxModel -from nbxsync.constants import ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS +from nbxsync.constants.template_pattern import TEMPLATE_PATTERN from nbxsync.models import SyncInfoModel, ZabbixConfigurationGroup __all__ = ('ZabbixMacroAssignment',) -TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') - class ZabbixMacroAssignment(SyncInfoModel, NetBoxModel): zabbixmacro = models.ForeignKey('nbxsync.ZabbixMacro', on_delete=models.CASCADE, related_name='zabbixmacroassignment') diff --git a/nbxsync/models/zabbixmaintenanceobjectassignment.py b/nbxsync/models/zabbixmaintenanceobjectassignment.py index 78e30526..e8a4a7e8 100644 --- a/nbxsync/models/zabbixmaintenanceobjectassignment.py +++ b/nbxsync/models/zabbixmaintenanceobjectassignment.py @@ -6,7 +6,7 @@ from netbox.models import NetBoxModel -from nbxsync.constants import MAINTENANCE_ASSIGNMENT_OBJECTS +from nbxsync.constants.assignment_models import MAINTENANCE_ASSIGNMENT_OBJECTS from nbxsync.models import ZabbixHostgroup __all__ = ('ZabbixMaintenanceObjectAssignment',) diff --git a/nbxsync/models/zabbixproxy.py b/nbxsync/models/zabbixproxy.py index 7cbd7cbc..91039ece 100644 --- a/nbxsync/models/zabbixproxy.py +++ b/nbxsync/models/zabbixproxy.py @@ -87,16 +87,23 @@ def clean(self): # Validate allowed_addresses if self.allowed_addresses: seen = set() - normalized = '' + invalid, duplicates, seen = [], [], set() for ip in self.allowed_addresses: try: normalized = str(ipaddress.ip_address(ip.strip())) except ValueError: - errors['allowed_addresses'] = f"'{ip}' is not a valid IP address." - + invalid.append(ip) + continue if normalized in seen: - errors['allowed_addresses'] = f'Duplicate IP address found: {normalized}' + duplicates.append(normalized) seen.add(normalized) + if invalid or duplicates: + msg = [] + if invalid: + msg.append(f'Invalid: {", ".join(invalid)}') + if duplicates: + msg.append(f'Duplicates: {", ".join(duplicates)}') + errors['allowed_addresses'] = '; '.join(msg) # Validate TLS PSK requirement if self.tls_connect == ZabbixTLSChoices.PSK or ZabbixTLSChoices.PSK in self.tls_accept: diff --git a/nbxsync/models/zabbixserverassignment.py b/nbxsync/models/zabbixserverassignment.py index cfc0733e..8ba14ee8 100644 --- a/nbxsync/models/zabbixserverassignment.py +++ b/nbxsync/models/zabbixserverassignment.py @@ -5,7 +5,7 @@ from netbox.models import NetBoxModel -from nbxsync.constants import ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS from nbxsync.models import SyncInfoModel __all__ = ('ZabbixServerAssignment',) diff --git a/nbxsync/models/zabbixtag.py b/nbxsync/models/zabbixtag.py index ac1bf3e8..11e71c33 100644 --- a/nbxsync/models/zabbixtag.py +++ b/nbxsync/models/zabbixtag.py @@ -1,12 +1,9 @@ -import re - from django.db import models from netbox.models import NetBoxModel +from nbxsync.constants.template_pattern import TEMPLATE_PATTERN __all__ = ('ZabbixTag',) -TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') - class ZabbixTag(NetBoxModel): name = models.CharField(max_length=512, blank=False) diff --git a/nbxsync/models/zabbixtagassignment.py b/nbxsync/models/zabbixtagassignment.py index 487e2024..f9548764 100644 --- a/nbxsync/models/zabbixtagassignment.py +++ b/nbxsync/models/zabbixtagassignment.py @@ -9,13 +9,12 @@ from netbox.models import NetBoxModel from utilities.jinja2 import render_jinja2 -from nbxsync.constants import ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS +from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS +from nbxsync.constants import TEMPLATE_PATTERN from nbxsync.models import ZabbixConfigurationGroup __all__ = ('ZabbixTagAssignment',) -TEMPLATE_PATTERN = re.compile(r'({{.*?}}|{%-?\s*.*?\s*-?%}|{#.*?#})') - class ZabbixTagAssignment(NetBoxModel): zabbixtag = models.ForeignKey(to='nbxsync.ZabbixTag', on_delete=models.CASCADE, related_name='zabbixtagassignment') diff --git a/nbxsync/models/zabbixtemplateassignment.py b/nbxsync/models/zabbixtemplateassignment.py index e689a37e..a62a062c 100644 --- a/nbxsync/models/zabbixtemplateassignment.py +++ b/nbxsync/models/zabbixtemplateassignment.py @@ -5,7 +5,7 @@ from netbox.models import NetBoxModel -from nbxsync.constants import ASSIGNMENT_MODELS +from nbxsync.constants.assignment_models import ASSIGNMENT_MODELS __all__ = ('ZabbixTemplateAssignment',) diff --git a/nbxsync/tests/models/test_zabbixhostinventory.py b/nbxsync/tests/models/test_zabbixhostinventory.py index 4119b09b..680c0da3 100644 --- a/nbxsync/tests/models/test_zabbixhostinventory.py +++ b/nbxsync/tests/models/test_zabbixhostinventory.py @@ -82,12 +82,20 @@ def test_render_field_truncates_long_value(self): self.assertEqual(len(rendered_value), 128) self.assertEqual(rendered_value, 'A' * 128) + @patch('nbxsync.models.zabbixhostinventory.logger') @patch('nbxsync.models.zabbixhostinventory.render_jinja2', side_effect=Exception('Unexpected!')) - def test_render_field_handles_unexpected_exception(self, mock_render): + def test_render_field_swallows_unexpected_exception_and_logs(self, mock_render, mock_logger): + """ + By design, render_field never propagates non-template exceptions, + a broken template on one device must not 500 the whole page. But + the traceback MUST be logged so ops can debug. + """ data = self.valid_data.copy() data['alias'] = '{{ any }}' inventory = ZabbixHostInventory.objects.create(**data) rendered_value, success = inventory.render_field('alias') + self.assertFalse(success) self.assertEqual(rendered_value, '') + mock_logger.exception.assert_called_once() diff --git a/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py b/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py index bdf5bda4..c040cac4 100644 --- a/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py +++ b/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py @@ -1,14 +1,16 @@ from unittest.mock import Mock, patch +from types import SimpleNamespace +from collections import defaultdict from django.db.models.query import QuerySet from django.contrib.contenttypes.models import ContentType -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from dcim.models import DeviceType, Manufacturer from utilities.testing import create_test_device from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixMacro, ZabbixMacroAssignment, ZabbixServer, ZabbixTag, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment, ZabbixConfigurationGroup, ZabbixConfigurationGroupAssignment -from nbxsync.utils.inheritance import resolve_inherited_zabbix_assignments +from nbxsync.utils.inheritance import get_assigned_zabbixobjects, resolve_inherited_zabbix_assignments, _merge_direct_and_inherited, _INHERITANCE_MODELS class ResolveInheritedAssignmentsTestCase(TestCase): @@ -105,3 +107,142 @@ def test_resolve_path_handles_queryset_first_none(self, mock_settings): # Verify .first() was actually called (hitting the missed line) dummy_qs.first.assert_called_once() + + +@patch('nbxsync.utils.inheritance.get_plugin_settings') +def test_direct_assignment_overrides_inherited_duplicate(self, mock_settings): + mock_settings.return_value.inheritance_chain = [('device_type',)] + + # setUp already attached self.template to self.device_type via + # ZabbixTemplateAssignment. Now attach the *same* template directly + # to the device, creating a direct/inherited collision on + # zabbixtemplate_id. + device_ct = ContentType.objects.get_for_model(self.device) + direct_assignment = ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=device_ct, assigned_object_id=self.device.pk) + + result = get_assigned_zabbixobjects(self.device) + templates = result['templates'] + + # Exactly one row for this template — direct wins over inherited. + self.assertEqual(len(templates), 1) + self.assertEqual(templates[0].pk, direct_assignment.pk) + self.assertEqual(templates[0].assigned_object_id, self.device.pk) + + # The surviving row is the direct one, so it must NOT carry the + # _inherited_from marker (only inherited rows get that attribute set). + self.assertFalse(hasattr(templates[0], '_inherited_from'), msg='Direct assignment survived merge but carries _inherited_from; this means the inherited duplicate leaked through and clobbered the direct one.') + + +@patch('nbxsync.utils.inheritance.get_plugin_settings') +def test_first_inheritance_path_wins_on_duplicate_template(self, mock_settings): + mock_settings.return_value.inheritance_chain = [ + ('device_type',), # PATH_LABELS -> 'Device Type' + ('device_type', 'manufacturer'), # PATH_LABELS -> 'Manufacturer' + ] + + manufacturer_ct = ContentType.objects.get_for_model(self.manufacturer) + ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=manufacturer_ct, assigned_object_id=self.manufacturer.pk) + + result = resolve_inherited_zabbix_assignments(self.device) + + self.assertEqual(len(result['templates']), 1) + template = next(iter(result['templates'].values())) + self.assertEqual(template.zabbixtemplate_id, self.template.id) + + # The heart of the regression: label must be from the FIRST path. + self.assertEqual(template._inherited_from, 'Device Type', msg='First-write-wins broken: an assignment from a later inheritance path overwrote the label from an earlier one.') + + +class MergeDirectAndInheritedTests(SimpleTestCase): + def test_inherited_duplicate_of_direct_is_skipped(self): + direct = [SimpleNamespace(zabbixtemplate_id=1, source='direct')] + inherited = { + 1: SimpleNamespace(zabbixtemplate_id=1, source='inherited-dup'), + 2: SimpleNamespace(zabbixtemplate_id=2, source='inherited-new'), + } + + result = _merge_direct_and_inherited(direct, inherited, 'zabbixtemplate_id') + + # The direct entry is preserved; the inherited row with id=1 is + # skipped by the `continue`; the inherited row with id=2 is appended. + self.assertEqual([obj.source for obj in result], ['direct', 'inherited-new']) + + def test_no_direct_means_all_inherited_flow_through(self): + # Guards against a future refactor that accidentally short-circuits + # the whole loop when direct_ids is empty. + direct = [] + inherited = {1: SimpleNamespace(zabbixtemplate_id=1)} + + result = _merge_direct_and_inherited(direct, inherited, 'zabbixtemplate_id') + + self.assertEqual(len(result), 1) + + +class ResolveInheritedFirstWriteWinsTests(SimpleTestCase): + """ + Regression: the `continue` inside `resolve_inherited_zabbix_assignments` + must reject an assignment whose dedup id was already seen from an + earlier path. Without it, a later path silently overwrites the + `_inherited_from` label of the earlier one. + """ + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + @patch('nbxsync.utils.inheritance._index_assignments') + @patch('nbxsync.utils.inheritance._resolve_parents') + def test_second_path_with_duplicate_id_is_skipped(self, mock_resolve_parents, mock_index_assignments, mock_settings): + # Two resolved paths, both pointing at the same (ct_id, pk) bucket + # keyed by (99, 1) in the index. Labels differ so we can tell which + # one won. + mock_settings.return_value.inheritance_chain = [ + ('device_type',), # PATH_LABELS -> 'Device Type' + ('device_type', 'manufacturer'), # PATH_LABELS -> 'Manufacturer' + ] + + parent_a = SimpleNamespace(pk=1) + parent_b = SimpleNamespace(pk=2) + mock_resolve_parents.return_value = ( + [ + (('device_type',), parent_a, 99), + (('device_type', 'manufacturer'), parent_b, 99), + ], + {99: {1, 2}}, + ) + + # For the FIRST assignment model (templates), return an entry at + # BOTH parent keys with the SAME dedup id. This is the collision + # that the `continue` protects against. For the remaining four + # models, return empty dicts. + dedup_attr = _INHERITANCE_MODELS[0][2] # 'zabbixtemplate_id' + shared_dedup_value = 4242 + + assignment_from_path_a = SimpleNamespace(**{dedup_attr: shared_dedup_value}) + assignment_from_path_b = SimpleNamespace(**{dedup_attr: shared_dedup_value}) + + templates_index = defaultdict( + list, + { + (99, 1): [assignment_from_path_a], # first path resolves here + (99, 2): [assignment_from_path_b], # second path resolves here + }, + ) + empty_indexes = [defaultdict(list) for _ in _INHERITANCE_MODELS[1:]] + mock_index_assignments.return_value = [templates_index, *empty_indexes] + + result = resolve_inherited_zabbix_assignments(SimpleNamespace()) + + # Exactly one entry — the second was rejected by the `continue`. + self.assertEqual(len(result['templates']), 1) + + # The surviving entry is the one from path A, so its + # _inherited_from label is 'Device Type', NOT 'Manufacturer'. + # If the `continue` were removed, the loop would fall through, + # set assignment_from_path_b._inherited_from = 'Manufacturer', + # and overwrite results[0][shared_dedup_value] with it. + surviving = next(iter(result['templates'].values())) + self.assertIs(surviving, assignment_from_path_a) + self.assertEqual(surviving._inherited_from, 'Device Type') + + # And the path-B assignment must NOT have been mutated — the + # `continue` fires *before* the `assignment._inherited_from = label` + # line, so it never gets the attribute. + self.assertFalse(hasattr(assignment_from_path_b, '_inherited_from')) diff --git a/nbxsync/utils/inheritance.py b/nbxsync/utils/inheritance.py index 133caa16..d8e9e27e 100644 --- a/nbxsync/utils/inheritance.py +++ b/nbxsync/utils/inheritance.py @@ -1,15 +1,51 @@ -from collections import OrderedDict +from collections import OrderedDict, defaultdict +from typing import Any, Callable, Iterable -from django.db.models import Model, QuerySet -from django.db.models.manager import BaseManager from django.contrib.contenttypes.models import ContentType +from django.db.models import Model, Q, QuerySet +from django.db.models.manager import BaseManager from nbxsync.constants import PATH_LABELS -from nbxsync.models import ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixHostInventory, ZabbixMacroAssignment, ZabbixTagAssignment, ZabbixTemplateAssignment, ZabbixConfigurationGroupAssignment +from nbxsync.models import ZabbixConfigurationGroupAssignment, ZabbixHostInterface, ZabbixHostInventory, ZabbixHostgroupAssignment, ZabbixMacroAssignment, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment from nbxsync.settings import get_plugin_settings from nbxsync.tables import ZabbixHostgroupAssignmentObjectViewTable, ZabbixMacroAssignmentObjectViewTable, ZabbixTagAssignmentObjectViewTable, ZabbixTemplateAssignmentObjectViewTable +def _template_server_filter(server): + return Q(zabbixtemplate__zabbixserver_id=server.id) + + +def _hostgroup_server_filter(server): + return Q(zabbixhostgroup__zabbixserver_id=server.id) + + +def _macro_server_filter(server): + """ + Macros are attached (via generic FK) to either a ZabbixServer or a + ZabbixTemplate. Include: + - macros whose assigned_object IS this ZabbixServer, or + - macros whose assigned_object is a ZabbixTemplate belonging to this + ZabbixServer. + """ + server_ct = ContentType.objects.get_for_model(server.__class__) + template_ct = ContentType.objects.get_for_model(ZabbixTemplate) + templates_on_server = ZabbixTemplate.objects.filter(zabbixserver_id=server.id).values('id') + + return Q(zabbixmacro__assigned_object_type=server_ct, zabbixmacro__assigned_object_id=server.id) | Q(zabbixmacro__assigned_object_type=template_ct, zabbixmacro__assigned_object_id__in=templates_on_server) + + +# Each row: (model, select_related field, dedup key attr, server-filter builder). +# server-filter builder is None for models that have no ZabbixServer relation. +_INHERITANCE_MODELS = ( + (ZabbixTemplateAssignment, 'zabbixtemplate', 'zabbixtemplate_id', _template_server_filter), + (ZabbixMacroAssignment, 'zabbixmacro', 'zabbixmacro_id', _macro_server_filter), + (ZabbixTagAssignment, 'zabbixtag', 'id', None), + (ZabbixHostgroupAssignment, 'zabbixhostgroup', 'zabbixhostgroup_id', _hostgroup_server_filter), + (ZabbixConfigurationGroupAssignment, 'zabbixconfigurationgroup', 'zabbixconfigurationgroup_id', None), +) +_RESULT_KEYS = ('templates', 'macros', 'tags', 'hostgroups', 'configurationgroups') + + def get_zabbixassignments_for_request(instance, request): """ Return Zabbix context for views/templates, including rendered tables. @@ -19,13 +55,16 @@ def get_zabbixassignments_for_request(instance, request): content_type = ContentType.objects.get_for_model(instance) def table_or_none(data, table_cls, attach_instance=False): - if data: - table = table_cls(data) - table.configure(request) - if attach_instance: - table.instance = instance - return table - return None + if not data: + return None + + table = table_cls(data) + table.configure(request) + + if attach_instance: + table.instance = instance + + return table return { 'zabbix_template_table': table_or_none(assignments['templates'], ZabbixTemplateAssignmentObjectViewTable), @@ -37,139 +76,192 @@ def table_or_none(data, table_cls, attach_instance=False): } +def _merge_direct_and_inherited(direct_list, inherited_map, key): + direct_ids = set() + for direct_obj in direct_list: + direct_id = getattr(direct_obj, key) + direct_ids.add(direct_id) + + extras = [] + for inherited_obj in inherited_map.values(): + inherited_id = getattr(inherited_obj, key) + if inherited_id in direct_ids: + continue + extras.append(inherited_obj) + + return direct_list + extras + + def get_assigned_zabbixobjects(instance, zabbixserver=None): """ - Return raw Zabbix assignment lists (direct + inherited) without any table formatting. + Return raw Zabbix assignment lists (direct + inherited) without any table + formatting. - When *zabbixserver* is given, server-scoped objects (templates, hostgroups, - host interfaces) are filtered to that server only. Macros, tags, inventory - and configuration-group assignments are server-agnostic and returned - unfiltered. + If ``zabbixserver`` is given, results are scoped to that server: + - Templates / Macros / Hostgroups / HostInterfaces are filtered to the + server they belong to (see ``_INHERITANCE_MODELS`` for the exact + traversal per model). + - Tags, ConfigurationGroups and HostInventory have no server relation + and are returned regardless. """ content_type = ContentType.objects.get_for_model(instance) + base = Q(assigned_object_type=content_type, assigned_object_id=instance.id) - # Direct assignments — server-scoped querysets are filtered conditionally - templates_qs = ZabbixTemplateAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixtemplate') - if zabbixserver: - templates_qs = templates_qs.filter(zabbixtemplate__zabbixserver=zabbixserver) - direct_templates = list(templates_qs) + def direct(model, select_field, server_filter): + qs = model.objects.filter(base).select_related(select_field) + if zabbixserver is not None and server_filter is not None: + qs = qs.filter(server_filter(zabbixserver)) - direct_macros = list(ZabbixMacroAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixmacro')) - direct_tags = list(ZabbixTagAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixtag')) + return qs - hostgroups_qs = ZabbixHostgroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixhostgroup') - if zabbixserver: - hostgroups_qs = hostgroups_qs.filter(zabbixhostgroup__zabbixserver=zabbixserver) - direct_hostgroups = list(hostgroups_qs) + direct_templates = list(direct(ZabbixTemplateAssignment, 'zabbixtemplate', _template_server_filter)) + direct_macros = list(direct(ZabbixMacroAssignment, 'zabbixmacro', _macro_server_filter)) + direct_tags = list(direct(ZabbixTagAssignment, 'zabbixtag', None)) + direct_hostgroups = list(direct(ZabbixHostgroupAssignment, 'zabbixhostgroup', _hostgroup_server_filter)) - hostinterfaces_qs = ZabbixHostInterface.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id) - if zabbixserver: - hostinterfaces_qs = hostinterfaces_qs.filter(zabbixserver=zabbixserver) - hostinterfaces = list(hostinterfaces_qs) + # HostInterfaces have their own zabbixserver FK (not routed through a nested assignment), so filter it directly. + hostinterface_qs = ZabbixHostInterface.objects.filter(base) + if zabbixserver is not None: + hostinterface_qs = hostinterface_qs.filter(zabbixserver_id=zabbixserver.id) - hostinventory = ZabbixHostInventory.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).first() - configurationgroup = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).first() + hostinterfaces = list(hostinterface_qs) - inherited = resolve_inherited_zabbix_assignments(instance, zabbixserver) + hostinventory = ZabbixHostInventory.objects.filter(base).first() + configurationgroup = ZabbixConfigurationGroupAssignment.objects.filter(base).first() - def merge(direct, inherited_map, key): - direct_ids = {getattr(obj, key) for obj in direct} - inherited_filtered = [obj for obj in inherited_map.values() if getattr(obj, key) not in direct_ids] - return direct + inherited_filtered + inherited = resolve_inherited_zabbix_assignments(instance, zabbixserver=zabbixserver) return { - 'templates': merge(direct_templates, inherited['templates'], 'zabbixtemplate_id'), - 'macros': merge(direct_macros, inherited['macros'], 'zabbixmacro_id'), - 'tags': merge(direct_tags, inherited['tags'], 'id'), - 'hostgroups': merge(direct_hostgroups, inherited['hostgroups'], 'zabbixhostgroup_id'), + 'templates': _merge_direct_and_inherited(direct_templates, inherited['templates'], 'zabbixtemplate_id'), + 'macros': _merge_direct_and_inherited(direct_macros, inherited['macros'], 'zabbixmacro_id'), + 'tags': _merge_direct_and_inherited(direct_tags, inherited['tags'], 'id'), + 'hostgroups': _merge_direct_and_inherited(direct_hostgroups, inherited['hostgroups'], 'zabbixhostgroup_id'), 'hostinterfaces': hostinterfaces, 'hostinventory': hostinventory, 'configurationgroup': configurationgroup, } -def resolve_inherited_zabbix_assignments(assigned_object, zabbixserver=None): - resolved_templates = OrderedDict() - resolved_macros = OrderedDict() - resolved_tags = OrderedDict() - resolved_hostgroups = OrderedDict() - resolved_configurationgroups = OrderedDict() - seen_template_ids = set() - seen_macro_ids = set() - seen_tag_ids = set() - seen_hostgroup_ids = set() - seen_configurationgroup_ids = set() - - def resolve_path(obj, path): - cur = obj - for attr in path: - cur = getattr(cur, attr, None) - if cur is None: - return None - # If the attribute is a manager or queryset, take the first related object - if isinstance(cur, (BaseManager, QuerySet)): - cur = cur.first() - # If it's something that still isn't a model instance after collapsing, bail - if cur is None: +def _walk_path(obj, path): + """ + Follow a dotted attribute path on a model instance. + + Django caches FK lookups on the instance (in ``_state.fields_cache``), + so sibling paths that share a prefix (e.g. ``('device',)`` and + ``('device', 'role')``) do not re-issue the FK query. + """ + + for attr in path: + obj = getattr(obj, attr, None) + if obj is None: + return None + + if isinstance(obj, (BaseManager, QuerySet)): + obj = obj.first() + if obj is None: return None - return cur + return obj if isinstance(obj, Model) else None + + +def _resolve_parents(assigned_object, paths): + """ + Walk every path once, returning ``(path, related_obj, ct_id)`` triples + (with ``None`` for paths that don't resolve) and a ``{ct_id: {pk, ...}}`` + map for batching the assignment queries. + """ + resolved = [] + pks_by_ct = defaultdict(set) + ct_id_by_model = {} + + for path in paths: + related_obj = _walk_path(assigned_object, path) + if related_obj is None: + resolved.append((path, None, None)) + continue + + model_cls = type(related_obj) + ct_id = ct_id_by_model.get(model_cls) + if ct_id is None: + ct_id = ContentType.objects.get_for_model(model_cls).id + ct_id_by_model[model_cls] = ct_id + + resolved.append((path, related_obj, ct_id)) + pks_by_ct[ct_id].add(related_obj.pk) + + return resolved, pks_by_ct + + +def _index_assignments(pks_by_ct, zabbixserver): + """ + Fetch all inherited assignments in exactly ``len(_INHERITANCE_MODELS)`` + queries and index each result set by ``(ct_id, object_id)``. + """ + if not pks_by_ct: + return [defaultdict(list) for _ in _INHERITANCE_MODELS] + + parent_filter = Q() + for ct_id, pks in pks_by_ct.items(): + parent_filter |= Q(assigned_object_type_id=ct_id, assigned_object_id__in=pks) + + indexes = [] + for model, select_field, _, server_filter in _INHERITANCE_MODELS: + qs = model.objects.filter(parent_filter).select_related(select_field) + if zabbixserver is not None and server_filter is not None: + qs = qs.filter(server_filter(zabbixserver)) + + idx = defaultdict(list) + for row in qs: + idx[(row.assigned_object_type_id, row.assigned_object_id)].append(row) + + indexes.append(idx) + + return indexes + + +def resolve_inherited_zabbix_assignments(assigned_object, zabbixserver=None): + """ + Walk the configured inheritance chain and collect Zabbix assignments this + object inherits from its parents (device → role → device_type → + manufacturer → platform → cluster → …). + + If ``zabbixserver`` is given, results are scoped to that server. Models + that have no server relation (tags, configuration groups) are returned + regardless. + + Deduplication is first-write-wins in inheritance-chain order, matching + the previous behaviour. Each returned assignment has ``_inherited_from`` + set to a human-readable path label. + + Query complexity: exactly ``len(_INHERITANCE_MODELS)`` assignment queries + regardless of chain length, plus at most one FK query per hop in the + chain (Django's instance-level FK cache means shared prefixes are free + after the first walk). + """ pluginsettings = get_plugin_settings() - for path in pluginsettings.inheritance_chain: - related_obj = resolve_path(assigned_object, path) - # label = '.'.join(path) + paths = tuple(pluginsettings.inheritance_chain) + + resolved, pks_by_ct = _resolve_parents(assigned_object, paths) + indexes = _index_assignments(pks_by_ct, zabbixserver) - if not related_obj: - # print(f'Path {label} not found or is None.') + results = [OrderedDict() for _ in _INHERITANCE_MODELS] + seen = [set() for _ in _INHERITANCE_MODELS] + + for path, related_obj, ct_id in resolved: + if related_obj is None: continue - ct = ContentType.objects.get_for_model(related_obj) - templates = ZabbixTemplateAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixtemplate') - macros = ZabbixMacroAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixmacro') - tags = ZabbixTagAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixtag') - hostgroups = ZabbixHostgroupAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixhostgroup') - configurationgroups = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixconfigurationgroup') - - if zabbixserver: - templates = templates.filter(zabbixtemplate__zabbixserver=zabbixserver) - hostgroups = hostgroups.filter(zabbixhostgroup__zabbixserver=zabbixserver) - - # print(f'[Resolved from {label}] {related_obj}: inherited {len(templates)} templates, {len(macros)} macros, {len(tags)} tags, {len(hostgroups)} hostgroups, {len(configurationgroups)} configurationgroups,') - - for template in templates: - if template.zabbixtemplate_id not in seen_template_ids: - template._inherited_from = PATH_LABELS.get(path, '.'.join(path)) - resolved_templates[template.zabbixtemplate_id] = template - seen_template_ids.add(template.zabbixtemplate_id) - - for macro in macros: - if macro.zabbixmacro_id not in seen_macro_ids: - macro._inherited_from = PATH_LABELS.get(path, '.'.join(path)) - resolved_macros[macro.zabbixmacro_id] = macro - seen_macro_ids.add(macro.zabbixmacro_id) - - for tag in tags: - if tag.id not in seen_tag_ids: - tag._inherited_from = PATH_LABELS.get(path, '.'.join(path)) - resolved_tags[tag.id] = tag - seen_tag_ids.add(tag.id) - - for hostgroup in hostgroups: - if hostgroup.zabbixhostgroup_id not in seen_hostgroup_ids: - hostgroup._inherited_from = PATH_LABELS.get(path, '.'.join(path)) - resolved_hostgroups[hostgroup.zabbixhostgroup_id] = hostgroup - seen_hostgroup_ids.add(hostgroup.zabbixhostgroup_id) - - for configurationgroup in configurationgroups: - if configurationgroup.zabbixconfigurationgroup_id not in seen_configurationgroup_ids: - configurationgroup._inherited_from = PATH_LABELS.get(path, '.'.join(path)) - resolved_configurationgroups[configurationgroup.zabbixconfigurationgroup_id] = configurationgroup - seen_configurationgroup_ids.add(configurationgroup.zabbixconfigurationgroup_id) + label = PATH_LABELS.get(path, '.'.join(path)) + key = (ct_id, related_obj.pk) - return { - 'templates': resolved_templates, - 'macros': resolved_macros, - 'tags': resolved_tags, - 'hostgroups': resolved_hostgroups, - 'configurationgroups': resolved_configurationgroups, - } + for i, (_, _, dedup_attr, _) in enumerate(_INHERITANCE_MODELS): + for assignment in indexes[i].get(key, ()): + dedup_value = getattr(assignment, dedup_attr) + if dedup_value in seen[i]: + continue + + assignment._inherited_from = label + results[i][dedup_value] = assignment + seen[i].add(dedup_value) + + return dict(zip(_RESULT_KEYS, results)) diff --git a/nbxsync/views/hostinfo.py b/nbxsync/views/hostinfo.py index b84e3509..61bb8b86 100644 --- a/nbxsync/views/hostinfo.py +++ b/nbxsync/views/hostinfo.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView -from nbxsync.constants import OBJECT_TYPE_MODEL_MAP +from nbxsync.constants.assignment_type_to_field import OBJECT_TYPE_MODEL_MAP from nbxsync.models import ZabbixServerAssignment from nbxsync.tables import ZabbixEventTable, ZabbixProblemTable from nbxsync.utils import ZabbixConnection diff --git a/nbxsync/views/jobs.py b/nbxsync/views/jobs.py index c37eaca2..357a5d1e 100644 --- a/nbxsync/views/jobs.py +++ b/nbxsync/views/jobs.py @@ -9,7 +9,7 @@ from nbxsync.models import ZabbixMaintenance, ZabbixProxy, ZabbixProxyGroup, ZabbixServer, ZabbixConfigurationGroup from nbxsync.utils.cfggroup.resync_zabbixconfiggroupassignment import resync_zabbixconfigurationgroupassignment -from nbxsync.constants import OBJECT_TYPE_MODEL_MAP +from nbxsync.constants.assignment_type_to_field import OBJECT_TYPE_MODEL_MAP __all__ = ( 'ZabbixSyncInfoModalView', From df29dfa5119941cbd3ed3d3e900a2d37c72f6e43 Mon Sep 17 00:00:00 2001 From: ahenobarbus Date: Fri, 10 Jul 2026 16:29:34 +0200 Subject: [PATCH 28/40] Address trigger dependency review feedback --- docs/configuration.md | 14 +- nbxsync/__init__.py | 12 +- nbxsync/settings.py | 14 +- nbxsync/tests/test_plugin_settings.py | 2 + .../utils/test_trigger_dependency_sync.py | 115 +++++++++- nbxsync/utils/trigger_dependency_sync.py | 200 +++++++++++------- 6 files changed, 246 insertions(+), 111 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 7b96a840..acf796c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -84,24 +84,16 @@ The plugin is configuration to do exactly what you want, by means of the plugin }, { 'name': 'switch', - 'roles': ['switch', 'switches'], + 'roles': ['switch', 'sw'], 'trigger_description': 'Switch status', }, { 'name': 'gateway', 'roles': [ 'gateway', - 'gateways', 'gw', 'firewall', - 'firewalls', - 'meraki gw', - 'meraki-gw', - 'meraki mx', - 'meraki-mx', - 'mx', - 'security appliance', - 'security-appliance', + 'router', ], 'trigger_description': 'Gateway status', }, @@ -226,7 +218,7 @@ For example, an access point role named `Wireless AP` will not match the default The default levels are ordered as access point, switch, gateway/firewall. With those defaults, a connected access point depends on its connected switch, and a connected switch depends on its connected gateway or firewall. Cable direction does not matter; nbxSync looks at directly connected devices and uses the level order to decide which device is the child and which device is the parent. -To support more device types, add their NetBox role names or slugs to the appropriate level. To support a different hierarchy, add or reorder levels from lowest child to highest parent. +To support more device types or vendor-specific role names, add their NetBox role names or slugs to the appropriate level. To support a different hierarchy, add or reorder levels from lowest child to highest parent. Existing Zabbix dependencies whose descriptions do not match the managed parent trigger descriptions are preserved. Dependencies matching the managed parent trigger descriptions are replaced with the current cabling-derived parent triggers. diff --git a/nbxsync/__init__.py b/nbxsync/__init__.py index b30d596e..1b8c85b2 100644 --- a/nbxsync/__init__.py +++ b/nbxsync/__init__.py @@ -121,24 +121,16 @@ class nbxSync(PluginConfig): }, { 'name': 'switch', - 'roles': ['switch', 'switches'], + 'roles': ['switch', 'sw'], 'trigger_description': 'Switch status', }, { 'name': 'gateway', 'roles': [ 'gateway', - 'gateways', 'gw', 'firewall', - 'firewalls', - 'meraki gw', - 'meraki-gw', - 'meraki mx', - 'meraki-mx', - 'mx', - 'security appliance', - 'security-appliance', + 'router', ], 'trigger_description': 'Gateway status', }, diff --git a/nbxsync/settings.py b/nbxsync/settings.py index 016d532e..23a4980e 100644 --- a/nbxsync/settings.py +++ b/nbxsync/settings.py @@ -69,6 +69,8 @@ def validate_role_tokens(cls, v: List[str]) -> List[str]: class TriggerDependencyConfig(BaseModel): + # TODO: Move trigger dependency levels to Django models so operators can + # manage roles and trigger descriptions through the UI/API without restart. enabled: bool = Field(default=False) levels: List[TriggerDependencyLevelConfig] = Field( default_factory=lambda: [ @@ -79,24 +81,16 @@ class TriggerDependencyConfig(BaseModel): ), TriggerDependencyLevelConfig( name='switch', - roles=['switch', 'switches'], + roles=['switch', 'sw'], trigger_description='Switch status', ), TriggerDependencyLevelConfig( name='gateway', roles=[ 'gateway', - 'gateways', 'gw', 'firewall', - 'firewalls', - 'meraki gw', - 'meraki-gw', - 'meraki mx', - 'meraki-mx', - 'mx', - 'security appliance', - 'security-appliance', + 'router', ], trigger_description='Gateway status', ), diff --git a/nbxsync/tests/test_plugin_settings.py b/nbxsync/tests/test_plugin_settings.py index ac6b8788..1d062b2a 100644 --- a/nbxsync/tests/test_plugin_settings.py +++ b/nbxsync/tests/test_plugin_settings.py @@ -28,6 +28,8 @@ def test_default_settings_model(self): self.assertIsInstance(settings.backgroundsync.maintenance, BackgroundSyncConfig) self.assertIsInstance(settings.trigger_dependencies, TriggerDependencyConfig) self.assertFalse(settings.trigger_dependencies.enabled) + self.assertEqual(settings.trigger_dependencies.levels[1].roles, ['switch', 'sw']) + self.assertEqual(settings.trigger_dependencies.levels[2].roles, ['gateway', 'gw', 'firewall', 'router']) def test_snmp_macro_validation_valid(self): config = SNMPConfig(snmp_community='{$VALID_COMM}', snmp_authpass='{$VALID_AUTH}', snmp_privpass='{$VALID_PRIV}') diff --git a/nbxsync/tests/utils/test_trigger_dependency_sync.py b/nbxsync/tests/utils/test_trigger_dependency_sync.py index 5bada30a..ab0dae01 100644 --- a/nbxsync/tests/utils/test_trigger_dependency_sync.py +++ b/nbxsync/tests/utils/test_trigger_dependency_sync.py @@ -1,10 +1,10 @@ from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.test import TestCase from nbxsync.settings import PluginSettingsModel -from nbxsync.utils.trigger_dependency_sync import build_dependency_payload, get_dependency_level, normalized_role_tokens, sync_device_trigger_dependencies +from nbxsync.utils.trigger_dependency_sync import build_dependency_payload, get_connected_devices, get_dependency_level, normalized_role_tokens, sync_device_trigger_dependencies class TriggerDependencySyncTestCase(TestCase): @@ -12,25 +12,28 @@ def setUp(self): self.trigger_config = PluginSettingsModel().trigger_dependencies def test_normalized_role_tokens_reads_name_and_slug(self): - device = SimpleNamespace(role=SimpleNamespace(name='Meraki MX', slug='meraki-mx')) + device = SimpleNamespace(role=SimpleNamespace(name='Branch Gateway', slug='branch-gateway')) - self.assertEqual(normalized_role_tokens(device), {'meraki mx', 'meraki-mx'}) + self.assertEqual(normalized_role_tokens(device), {'branch gateway', 'branch-gateway'}) def test_role_matching_uses_configured_level_tokens(self): access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) switch = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) - gateway = SimpleNamespace(role=SimpleNamespace(name='Security Appliance', slug='security-appliance')) + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway')) firewall = SimpleNamespace(role=SimpleNamespace(name='Firewall', slug='firewall')) + router = SimpleNamespace(role=SimpleNamespace(name='Router', slug='router')) access_point_index, access_point_level = get_dependency_level(access_point, trigger_config=self.trigger_config) switch_index, switch_level = get_dependency_level(switch, trigger_config=self.trigger_config) gateway_index, gateway_level = get_dependency_level(gateway, trigger_config=self.trigger_config) firewall_index, firewall_level = get_dependency_level(firewall, trigger_config=self.trigger_config) + router_index, router_level = get_dependency_level(router, trigger_config=self.trigger_config) self.assertEqual((access_point_index, access_point_level.name), (0, 'access_point')) self.assertEqual((switch_index, switch_level.name), (1, 'switch')) self.assertEqual((gateway_index, gateway_level.name), (2, 'gateway')) self.assertEqual((firewall_index, firewall_level.name), (2, 'gateway')) + self.assertEqual((router_index, router_level.name), (2, 'gateway')) def test_build_dependency_payload_preserves_unmanaged_dependencies(self): child_trigger = { @@ -61,10 +64,34 @@ def test_build_dependency_payload_deduplicates_parent_dependencies(self): self.assertEqual(payload, [{'triggerid': '300'}]) + @patch('nbxsync.utils.trigger_dependency_sync.Interface.objects') + @patch('nbxsync.utils.trigger_dependency_sync.ContentType.objects.get_for_model') + def test_get_connected_devices_bulk_fetches_remote_interfaces(self, mock_get_content_type, mock_interface_objects): + interface_content_type_id = 10 + remote_interface_id = 101 + remote_device = SimpleNamespace(pk=201) + remote_interface = SimpleNamespace(pk=remote_interface_id, device=remote_device) + path = SimpleNamespace( + is_complete=True, + path=[ + [f'{interface_content_type_id}:1'], + ['11:50'], + [f'{interface_content_type_id}:{remote_interface_id}'], + ], + ) + local_interface = SimpleNamespace(pk=1, _path=path) + device = SimpleNamespace(interfaces=FakeInterfaceManager([local_interface])) + mock_get_content_type.return_value = SimpleNamespace(id=interface_content_type_id) + mock_interface_objects.filter.return_value.select_related.return_value = [remote_interface] + + self.assertEqual(get_connected_devices(device), [remote_device]) + mock_interface_objects.filter.assert_called_once_with(pk__in=[remote_interface_id]) + mock_interface_objects.filter.return_value.select_related.assert_called_once_with('device', 'device__role') + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices', return_value=[]) - @patch('nbxsync.utils.trigger_dependency_sync._sync_child_dependency', return_value={'child': 'ap', 'changed': True}) + @patch('nbxsync.utils.trigger_dependency_sync._sync_child_dependencies', return_value=[{'child': 'ap', 'changed': True}]) @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') - def test_sync_device_trigger_dependencies_syncs_lowest_level_device(self, mock_settings, mock_sync_child, _mock_get_children): + def test_sync_device_trigger_dependencies_syncs_lowest_level_device(self, mock_settings, mock_sync_children, _mock_get_children): self.trigger_config.enabled = True mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) access_point = SimpleNamespace( @@ -75,4 +102,76 @@ def test_sync_device_trigger_dependencies_syncs_lowest_level_device(self, mock_s result = sync_device_trigger_dependencies(access_point) self.assertEqual(result, [{'child': 'ap', 'changed': True}]) - mock_sync_child.assert_called_once_with(access_point, trigger_config=self.trigger_config) + mock_sync_children.assert_called_once_with([access_point], trigger_config=self.trigger_config) + + @patch('nbxsync.utils.trigger_dependency_sync.ZabbixConnection') + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignment') + @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_device_trigger_dependencies_reuses_connection_per_zabbix_server( + self, + mock_settings, + mock_get_children, + mock_get_parents, + mock_get_assignment, + mock_connection, + ): + self.trigger_config.enabled = True + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + zabbixserver = SimpleNamespace(name='Zabbix 1') + gateway = SimpleNamespace( + role=SimpleNamespace(name='Gateway', slug='gateway'), + _meta=SimpleNamespace(model_name='device'), + ) + child_1 = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + child_2 = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + child_1_assignment = SimpleNamespace(hostid='101', zabbixserver_id=1, zabbixserver=zabbixserver) + child_2_assignment = SimpleNamespace(hostid='102', zabbixserver_id=1, zabbixserver=zabbixserver) + parent_assignment = SimpleNamespace(hostid='201', zabbixserver_id=1, zabbixserver=zabbixserver) + api = MagicMock() + mock_connection.return_value.__enter__.return_value = api + mock_get_children.return_value = [child_1, child_2] + mock_get_parents.return_value = [gateway] + + def assignment_for(device): + if device is child_1: + return child_1_assignment + if device is child_2: + return child_2_assignment + if device is gateway: + return parent_assignment + return None + + def trigger_for(_api, hostid, description): + if description == 'AP status': + return {'triggerid': f'child-{hostid}', 'description': description, 'dependencies': []} + return {'triggerid': f'parent-{hostid}', 'description': description, 'dependencies': []} + + mock_get_assignment.side_effect = assignment_for + + with patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger', side_effect=trigger_for): + result = sync_device_trigger_dependencies(gateway) + + mock_connection.assert_called_once_with(zabbixserver) + self.assertEqual(api.trigger.update.call_count, 2) + self.assertEqual( + result, + [ + {'child': str(child_1), 'parent': str(gateway), 'changed': True}, + {'child': str(child_2), 'parent': str(gateway), 'changed': True}, + ], + ) + + +class FakeInterfaceManager: + def __init__(self, interfaces): + self.interfaces = interfaces + + def select_related(self, *fields): + self.selected_fields = fields + return self + + def order_by(self, *fields): + self.ordered_fields = fields + return self.interfaces diff --git a/nbxsync/utils/trigger_dependency_sync.py b/nbxsync/utils/trigger_dependency_sync.py index 4c2b2ae3..f36f5b57 100644 --- a/nbxsync/utils/trigger_dependency_sync.py +++ b/nbxsync/utils/trigger_dependency_sync.py @@ -2,6 +2,9 @@ from django.contrib.contenttypes.models import ContentType +from dcim.models import Interface +from dcim.utils import decompile_path_node + from nbxsync.models import ZabbixServerAssignment from nbxsync.settings import get_plugin_settings from nbxsync.utils import ZabbixConnection @@ -67,21 +70,32 @@ def get_host_assignment(device): return assignments[0] if assignments else None -def get_primary_remote_device(interface): - endpoints = list(interface.connected_endpoints or []) - if not endpoints: - return None - - remote_interface = endpoints[0] - return getattr(remote_interface, 'device', None) - - def get_connected_devices(device): devices = [] seen_ids = set() + remote_interface_ids = [] + + interfaces = list(device.interfaces.select_related('_path').order_by('name')) + interface_content_type_id = ContentType.objects.get_for_model(Interface).id - for interface in device.interfaces.all().order_by('name'): - remote_device = get_primary_remote_device(interface) + for interface in interfaces: + path = getattr(interface, '_path', None) + if not path or not path.is_complete: + continue + + for node in path.path[-1]: + content_type_id, object_id = decompile_path_node(node) + if content_type_id == interface_content_type_id: + remote_interface_ids.append(object_id) + + remote_interfaces = Interface.objects.filter(pk__in=remote_interface_ids).select_related('device', 'device__role') + remote_interfaces_by_id = {interface.pk: interface for interface in remote_interfaces} + + for remote_interface_id in remote_interface_ids: + remote_interface = remote_interfaces_by_id.get(remote_interface_id) + if not remote_interface: + continue + remote_device = remote_interface.device if not remote_device or remote_device.pk in seen_ids: continue @@ -160,9 +174,6 @@ def build_dependency_payload(child_trigger, parent_triggerids, managed_parent_de def sync_device_trigger_dependencies(device): trigger_config = get_plugin_settings().trigger_dependencies - if not trigger_config.enabled: - return [] - if getattr(device._meta, 'model_name', None) != 'device': logger.debug('Skipping trigger dependency sync for unsupported object type: %s', device) return [] @@ -172,22 +183,40 @@ def sync_device_trigger_dependencies(device): logger.debug('Skipping trigger dependency sync for unsupported role on %s.', device) return [] - results = [] + children_to_sync = [] if device_level_index < len(trigger_config.levels) - 1: - result = _sync_child_dependency(device, trigger_config=trigger_config) - if result: - results.append(result) + children_to_sync.append(device) + + children_to_sync.extend(get_child_devices(device, trigger_config=trigger_config)) + return _sync_child_dependencies(children_to_sync, trigger_config=trigger_config) - for child in get_child_devices(device, trigger_config=trigger_config): - result = _sync_child_dependency(child, trigger_config=trigger_config) - if result: - results.append(result) + +def _sync_child_dependencies(children, trigger_config=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + prepared_by_server = {} + + for child in children: + prepared = _prepare_child_dependency_sync(child, trigger_config=trigger_config) + if not prepared: + continue + + server_id = prepared['child_assignment'].zabbixserver_id + prepared_by_server.setdefault(server_id, []).append(prepared) + + results = [] + for prepared_children in prepared_by_server.values(): + zabbixserver = prepared_children[0]['child_assignment'].zabbixserver + with ZabbixConnection(zabbixserver) as api: + for prepared in prepared_children: + result = _sync_prepared_child_dependency(prepared, api, trigger_config=trigger_config) + if result: + results.append(result) return results -def _sync_child_dependency(child, trigger_config=None): +def _prepare_child_dependency_sync(child, trigger_config=None): trigger_config = trigger_config or get_plugin_settings().trigger_dependencies child_level_index, child_level = get_dependency_level(child, trigger_config=trigger_config) if child_level_index is None: @@ -204,52 +233,79 @@ def _sync_child_dependency(child, trigger_config=None): logger.warning('No Zabbix host assignment with hostid found for %s.', child) return None - with ZabbixConnection(child_assignment.zabbixserver) as api: - child_trigger = get_host_trigger(api, child_assignment.hostid, child_level.trigger_description) - if not child_trigger: - logger.warning('No "%s" trigger found for %s.', child_level.trigger_description, child) - return None - - parent_triggers = [] - parent_names = [] - for parent in parent_devices: - _, parent_level = get_dependency_level(parent, trigger_config=trigger_config) - parent_assignment = get_host_assignment(parent) - if not parent_assignment: - logger.warning('No Zabbix host assignment with hostid found for parent %s.', parent) - continue - - if child_assignment.zabbixserver_id != parent_assignment.zabbixserver_id: - logger.warning( - 'Skipping dependency sync for %s: child and parent %s are assigned to different Zabbix servers.', - child, - parent, - ) - continue - - parent_trigger = get_host_trigger(api, parent_assignment.hostid, parent_level.trigger_description) - if not parent_trigger: - logger.warning('No "%s" trigger found for %s.', parent_level.trigger_description, parent) - continue - - parent_triggers.append(parent_trigger) - parent_names.append(str(parent)) - - if not parent_triggers: - return None - - dependency_payload = build_dependency_payload( - child_trigger, - [trigger['triggerid'] for trigger in parent_triggers], - get_managed_trigger_descriptions(trigger_config=trigger_config), - ) - current_ids = {dep.get('triggerid') for dep in child_trigger.get('dependencies', []) if dep.get('triggerid')} - desired_ids = {dep.get('triggerid') for dep in dependency_payload} - - if current_ids == desired_ids: - logger.info('Dependency already correct: %s -> %s.', child, ', '.join(parent_names)) - return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': False} - - api.trigger.update(triggerid=child_trigger['triggerid'], dependencies=dependency_payload) - logger.info('Updated dependency: %s -> %s.', child, ', '.join(parent_names)) - return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': True} + return { + 'child': child, + 'child_level': child_level, + 'parent_devices': parent_devices, + 'child_assignment': child_assignment, + } + + +def _sync_child_dependency(child, trigger_config=None, api=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + prepared = _prepare_child_dependency_sync(child, trigger_config=trigger_config) + if not prepared: + return None + + if api is not None: + return _sync_prepared_child_dependency(prepared, api, trigger_config=trigger_config) + + with ZabbixConnection(prepared['child_assignment'].zabbixserver) as connection: + return _sync_prepared_child_dependency(prepared, connection, trigger_config=trigger_config) + + +def _sync_prepared_child_dependency(prepared, api, trigger_config=None): + trigger_config = trigger_config or get_plugin_settings().trigger_dependencies + child = prepared['child'] + child_level = prepared['child_level'] + parent_devices = prepared['parent_devices'] + child_assignment = prepared['child_assignment'] + + child_trigger = get_host_trigger(api, child_assignment.hostid, child_level.trigger_description) + if not child_trigger: + logger.warning('No "%s" trigger found for %s.', child_level.trigger_description, child) + return None + + parent_triggers = [] + parent_names = [] + for parent in parent_devices: + _, parent_level = get_dependency_level(parent, trigger_config=trigger_config) + parent_assignment = get_host_assignment(parent) + if not parent_assignment: + logger.warning('No Zabbix host assignment with hostid found for parent %s.', parent) + continue + + if child_assignment.zabbixserver_id != parent_assignment.zabbixserver_id: + logger.warning( + 'Skipping dependency sync for %s: child and parent %s are assigned to different Zabbix servers.', + child, + parent, + ) + continue + + parent_trigger = get_host_trigger(api, parent_assignment.hostid, parent_level.trigger_description) + if not parent_trigger: + logger.warning('No "%s" trigger found for %s.', parent_level.trigger_description, parent) + continue + + parent_triggers.append(parent_trigger) + parent_names.append(str(parent)) + + if not parent_triggers: + return None + + dependency_payload = build_dependency_payload( + child_trigger, + [trigger['triggerid'] for trigger in parent_triggers], + get_managed_trigger_descriptions(trigger_config=trigger_config), + ) + current_ids = {dep.get('triggerid') for dep in child_trigger.get('dependencies', []) if dep.get('triggerid')} + desired_ids = {dep.get('triggerid') for dep in dependency_payload} + + if current_ids == desired_ids: + logger.info('Dependency already correct: %s -> %s.', child, ', '.join(parent_names)) + return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': False} + + api.trigger.update(triggerid=child_trigger['triggerid'], dependencies=dependency_payload) + logger.info('Updated dependency: %s -> %s.', child, ', '.join(parent_names)) + return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': True} From b33ae507fc14980bd309b95b9ec9bd7fdfadd184 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 21:31:14 +0200 Subject: [PATCH 29/40] Fix test --- nbxsync/tests/jobs/test_synchost.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index 7cd5788d..84963002 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -12,6 +12,7 @@ from nbxsync.jobs.synchost import SyncHostJob from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixProxy, ZabbixProxyGroup, ZabbixServer, ZabbixServerAssignment from nbxsync.utils.sync import ProxyGroupSync +from nbxsync.settings import get_plugin_settings class SyncHostJobTestCase(TestCase): From d07b6e5d1f9b8ed67b911b7eeb232b185c1fd2e0 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 21:41:43 +0200 Subject: [PATCH 30/40] Cleanup tests --- ...st_resolve_inherited_zabbix_assignments.py | 44 ------------------- .../utils/test_trigger_dependency_sync.py | 1 - nbxsync/tests/views/test_hostinfo.py | 38 ---------------- 3 files changed, 83 deletions(-) diff --git a/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py b/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py index c040cac4..6af2df27 100644 --- a/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py +++ b/nbxsync/tests/utils/test_resolve_inherited_zabbix_assignments.py @@ -109,50 +109,6 @@ def test_resolve_path_handles_queryset_first_none(self, mock_settings): dummy_qs.first.assert_called_once() -@patch('nbxsync.utils.inheritance.get_plugin_settings') -def test_direct_assignment_overrides_inherited_duplicate(self, mock_settings): - mock_settings.return_value.inheritance_chain = [('device_type',)] - - # setUp already attached self.template to self.device_type via - # ZabbixTemplateAssignment. Now attach the *same* template directly - # to the device, creating a direct/inherited collision on - # zabbixtemplate_id. - device_ct = ContentType.objects.get_for_model(self.device) - direct_assignment = ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=device_ct, assigned_object_id=self.device.pk) - - result = get_assigned_zabbixobjects(self.device) - templates = result['templates'] - - # Exactly one row for this template — direct wins over inherited. - self.assertEqual(len(templates), 1) - self.assertEqual(templates[0].pk, direct_assignment.pk) - self.assertEqual(templates[0].assigned_object_id, self.device.pk) - - # The surviving row is the direct one, so it must NOT carry the - # _inherited_from marker (only inherited rows get that attribute set). - self.assertFalse(hasattr(templates[0], '_inherited_from'), msg='Direct assignment survived merge but carries _inherited_from; this means the inherited duplicate leaked through and clobbered the direct one.') - - -@patch('nbxsync.utils.inheritance.get_plugin_settings') -def test_first_inheritance_path_wins_on_duplicate_template(self, mock_settings): - mock_settings.return_value.inheritance_chain = [ - ('device_type',), # PATH_LABELS -> 'Device Type' - ('device_type', 'manufacturer'), # PATH_LABELS -> 'Manufacturer' - ] - - manufacturer_ct = ContentType.objects.get_for_model(self.manufacturer) - ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=manufacturer_ct, assigned_object_id=self.manufacturer.pk) - - result = resolve_inherited_zabbix_assignments(self.device) - - self.assertEqual(len(result['templates']), 1) - template = next(iter(result['templates'].values())) - self.assertEqual(template.zabbixtemplate_id, self.template.id) - - # The heart of the regression: label must be from the FIRST path. - self.assertEqual(template._inherited_from, 'Device Type', msg='First-write-wins broken: an assignment from a later inheritance path overwrote the label from an earlier one.') - - class MergeDirectAndInheritedTests(SimpleTestCase): def test_inherited_duplicate_of_direct_is_skipped(self): direct = [SimpleNamespace(zabbixtemplate_id=1, source='direct')] diff --git a/nbxsync/tests/utils/test_trigger_dependency_sync.py b/nbxsync/tests/utils/test_trigger_dependency_sync.py index ab0dae01..a4913576 100644 --- a/nbxsync/tests/utils/test_trigger_dependency_sync.py +++ b/nbxsync/tests/utils/test_trigger_dependency_sync.py @@ -141,7 +141,6 @@ def assignment_for(device): return child_2_assignment if device is gateway: return parent_assignment - return None def trigger_for(_api, hostid, description): if description == 'AP status': diff --git a/nbxsync/tests/views/test_hostinfo.py b/nbxsync/tests/views/test_hostinfo.py index 9cb7f39b..3cd2a700 100644 --- a/nbxsync/tests/views/test_hostinfo.py +++ b/nbxsync/tests/views/test_hostinfo.py @@ -60,41 +60,3 @@ def test_open_event_after_recovered_event_uses_own_state(self, mock_conn): open_row = next(r for r in rows if r['eventid'] == '3') self.assertIsNone(open_row['end_time']) self.assertIsNone(open_row['duration']) - - -@patch('nbxsync.views.hostinfo.ZabbixConnection') -def test_events_from_multiple_servers_are_all_kept(self, mock_conn): - """ - Regression: `event_list = []` used to live inside the per-server loop, so - the second server's events wiped the first's. - """ - server_b = ZabbixServer.objects.create(name='ZBX-B', url='http://b.example', token='t') - ZabbixServerAssignment.objects.create(zabbixserver=server_b, hostid=202, assigned_object_type=ContentType.objects.get_for_model(Device), assigned_object_id=self.device.pk) - - responses = { - 101: [_make_event('1', '1000')], # from server A - 202: [_make_event('2', '2000')], # from server B - } - - def enter(instance): - # Which server this connection is for — read from the arg the view passed. - server = instance.call_args.args[0] - api = MagicMock() - api.event.get.side_effect = lambda **kw: [] if 'eventids' in kw else responses[ZabbixServerAssignment.objects.get(zabbixserver=server).hostid] - return api - - mock_conn.return_value.__enter__.side_effect = lambda: enter(mock_conn) - - response = self.client.get(reverse('plugins:nbxsync:zabbixhost_events', kwargs={'objtype': 'device', 'pk': self.device.pk})) - eventids = {r.record['eventid'] for r in response.context['table'].rows} - self.assertEqual(eventids, {'1', '2'}) - - -@patch('nbxsync.views.hostinfo.ZabbixConnection') -def test_unreachable_server_shows_in_fetch_errors(self, mock_conn): - mock_conn.side_effect = ConnectionError('boom') - - response = self.client.get(reverse('plugins:nbxsync:zabbixhost_events', kwargs={'objtype': 'device', 'pk': self.device.pk})) - self.assertEqual(response.status_code, 200) - self.assertIn(self.server, response.context['fetch_errors']) - self.assertEqual(list(response.context['table'].rows), []) From e437ad3fbb5d46b4091e51ef8667f2f6f3214dfb Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 21:47:07 +0200 Subject: [PATCH 31/40] Remove dead code --- nbxsync/utils/trigger_dependency_sync.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/nbxsync/utils/trigger_dependency_sync.py b/nbxsync/utils/trigger_dependency_sync.py index f36f5b57..759865aa 100644 --- a/nbxsync/utils/trigger_dependency_sync.py +++ b/nbxsync/utils/trigger_dependency_sync.py @@ -241,19 +241,6 @@ def _prepare_child_dependency_sync(child, trigger_config=None): } -def _sync_child_dependency(child, trigger_config=None, api=None): - trigger_config = trigger_config or get_plugin_settings().trigger_dependencies - prepared = _prepare_child_dependency_sync(child, trigger_config=trigger_config) - if not prepared: - return None - - if api is not None: - return _sync_prepared_child_dependency(prepared, api, trigger_config=trigger_config) - - with ZabbixConnection(prepared['child_assignment'].zabbixserver) as connection: - return _sync_prepared_child_dependency(prepared, connection, trigger_config=trigger_config) - - def _sync_prepared_child_dependency(prepared, api, trigger_config=None): trigger_config = trigger_config or get_plugin_settings().trigger_dependencies child = prepared['child'] From aa62a445e7fcd34943b9eb40143b67e95f9297a5 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 22:53:36 +0200 Subject: [PATCH 32/40] Fix code and tests to handle multi-server --- nbxsync/jobs/synchost.py | 11 +- nbxsync/tests/jobs/test_synchost.py | 15 + .../utils/test_trigger_dependency_sync.py | 580 +++++++++++++++++- nbxsync/utils/trigger_dependency_sync.py | 119 ++-- 4 files changed, 644 insertions(+), 81 deletions(-) diff --git a/nbxsync/jobs/synchost.py b/nbxsync/jobs/synchost.py index 2dba3efc..321bb80c 100644 --- a/nbxsync/jobs/synchost.py +++ b/nbxsync/jobs/synchost.py @@ -1,3 +1,5 @@ +import logging + from django.contrib.contenttypes.models import ContentType from nbxsync.choices.zabbixstatus import ZabbixHostStatus @@ -9,6 +11,8 @@ from nbxsync.utils.sync.safe_sync import safe_sync from nbxsync.utils.trigger_dependency_sync import sync_device_trigger_dependencies +logger = logging.getLogger(__name__) + __all__ = ('SyncHostJob',) @@ -38,8 +42,11 @@ def run(self): self.sync_host(assignment) self.verify_hostinterfaces(assignment) - if object_type == 'device' and zabbix_status != ZabbixHostStatus.DELETED and pluginsettings.trigger_dependencies.enabled: - sync_device_trigger_dependencies(self.instance) + if object_type == 'device' and zabbix_status != ZabbixHostStatus.DELETED and pluginsettings.trigger_dependencies.enabled: + try: + sync_device_trigger_dependencies(self.instance) + except Exception: + logger.exception('Trigger dependency sync failed for %s; continuing.', self.instance) def delete_host(self, assignment): safe_delete(HostSync, assignment) diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index 84963002..ad55df94 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -119,6 +119,21 @@ def test_run_syncs_trigger_dependencies_when_enabled(self, mock_sync_dependencie mock_sync_dependencies.assert_called_once_with(self.device) + @patch('nbxsync.jobs.synchost.sync_device_trigger_dependencies') + def test_run_swallows_trigger_dependency_sync_exception(self, mock_sync_dependencies): + """A Zabbix API failure inside dep sync must not fail the surrounding host sync.""" + get_plugin_settings().trigger_dependencies.enabled = True + mock_sync_dependencies.side_effect = Exception('Zabbix API timeout') + + job = SyncHostJob(instance=self.device) + + with self.assertLogs('nbxsync.jobs.synchost', level='ERROR') as log_ctx: + job.run() # must not raise + + mock_sync_dependencies.assert_called_once_with(self.device) + log_text = '\n'.join(log_ctx.output) + self.assertIn('Zabbix API timeout', log_text) + def test_run_sync_host_deleted(self): self.device.status = 'decommissioning' self.device.save() diff --git a/nbxsync/tests/utils/test_trigger_dependency_sync.py b/nbxsync/tests/utils/test_trigger_dependency_sync.py index a4913576..3c74d4d5 100644 --- a/nbxsync/tests/utils/test_trigger_dependency_sync.py +++ b/nbxsync/tests/utils/test_trigger_dependency_sync.py @@ -4,7 +4,23 @@ from django.test import TestCase from nbxsync.settings import PluginSettingsModel -from nbxsync.utils.trigger_dependency_sync import build_dependency_payload, get_connected_devices, get_dependency_level, normalized_role_tokens, sync_device_trigger_dependencies +from nbxsync.utils.trigger_dependency_sync import ( + _prepare_child_dependency_sync, + _sync_child_dependencies, + _sync_prepared_child_dependency, + _token_set, + build_dependency_payload, + get_child_devices, + get_connected_devices, + get_dependency_level, + get_host_assignments, + get_host_trigger, + get_managed_trigger_descriptions, + get_parent_devices, + get_server_assignments, + normalized_role_tokens, + sync_device_trigger_dependencies, +) class TriggerDependencySyncTestCase(TestCase): @@ -44,11 +60,7 @@ def test_build_dependency_payload_preserves_unmanaged_dependencies(self): ], } - payload = build_dependency_payload( - child_trigger, - ['300'], - {self.trigger_config.levels[1].trigger_description}, - ) + payload = build_dependency_payload(child_trigger, ['300'], {self.trigger_config.levels[1].trigger_description}) self.assertEqual(payload, [{'triggerid': '200'}, {'triggerid': '300'}]) @@ -94,10 +106,7 @@ def test_get_connected_devices_bulk_fetches_remote_interfaces(self, mock_get_con def test_sync_device_trigger_dependencies_syncs_lowest_level_device(self, mock_settings, mock_sync_children, _mock_get_children): self.trigger_config.enabled = True mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) - access_point = SimpleNamespace( - role=SimpleNamespace(name='Access Point', slug='access-point'), - _meta=SimpleNamespace(model_name='device'), - ) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point'), _meta=SimpleNamespace(model_name='device')) result = sync_device_trigger_dependencies(access_point) @@ -105,50 +114,44 @@ def test_sync_device_trigger_dependencies_syncs_lowest_level_device(self, mock_s mock_sync_children.assert_called_once_with([access_point], trigger_config=self.trigger_config) @patch('nbxsync.utils.trigger_dependency_sync.ZabbixConnection') - @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignment') + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignments') @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') - def test_sync_device_trigger_dependencies_reuses_connection_per_zabbix_server( - self, - mock_settings, - mock_get_children, - mock_get_parents, - mock_get_assignment, - mock_connection, - ): + def test_sync_device_trigger_dependencies_reuses_connection_per_zabbix_server(self, mock_settings, mock_get_children, mock_get_parents, mock_get_assignments, mock_connection): + """All devices on one shared server → single ZabbixConnection reused across children.""" self.trigger_config.enabled = True mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + zabbixserver = SimpleNamespace(name='Zabbix 1') - gateway = SimpleNamespace( - role=SimpleNamespace(name='Gateway', slug='gateway'), - _meta=SimpleNamespace(model_name='device'), - ) + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway'), _meta=SimpleNamespace(model_name='device')) child_1 = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) child_2 = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) child_1_assignment = SimpleNamespace(hostid='101', zabbixserver_id=1, zabbixserver=zabbixserver) child_2_assignment = SimpleNamespace(hostid='102', zabbixserver_id=1, zabbixserver=zabbixserver) parent_assignment = SimpleNamespace(hostid='201', zabbixserver_id=1, zabbixserver=zabbixserver) - api = MagicMock() - mock_connection.return_value.__enter__.return_value = api + mock_get_children.return_value = [child_1, child_2] mock_get_parents.return_value = [gateway] - def assignment_for(device): + def assignments_for(device): if device is child_1: - return child_1_assignment + return {1: child_1_assignment} if device is child_2: - return child_2_assignment + return {1: child_2_assignment} if device is gateway: - return parent_assignment + return {1: parent_assignment} + + mock_get_assignments.side_effect = assignments_for + + api = MagicMock() + mock_connection.return_value.__enter__.return_value = api def trigger_for(_api, hostid, description): if description == 'AP status': return {'triggerid': f'child-{hostid}', 'description': description, 'dependencies': []} return {'triggerid': f'parent-{hostid}', 'description': description, 'dependencies': []} - mock_get_assignment.side_effect = assignment_for - with patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger', side_effect=trigger_for): result = sync_device_trigger_dependencies(gateway) @@ -157,11 +160,522 @@ def trigger_for(_api, hostid, description): self.assertEqual( result, [ - {'child': str(child_1), 'parent': str(gateway), 'changed': True}, - {'child': str(child_2), 'parent': str(gateway), 'changed': True}, + {'child': str(child_1), 'parent': str(gateway), 'server': str(zabbixserver), 'changed': True}, + {'child': str(child_2), 'parent': str(gateway), 'server': str(zabbixserver), 'changed': True}, ], ) + @patch('nbxsync.utils.trigger_dependency_sync.ZabbixConnection') + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignments') + @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_uses_a_connection_per_shared_zabbix_server(self, mock_settings, mock_get_children, mock_get_parents, mock_get_assignments, mock_connection): + """Child and parent both assigned to servers A and B → one connection per server, both updated.""" + self.trigger_config.enabled = True + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + + server_a = SimpleNamespace(name='Zabbix A') + server_b = SimpleNamespace(name='Zabbix B') + + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway'), _meta=SimpleNamespace(model_name='device')) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + + ap_assignment_a = SimpleNamespace(hostid='101', zabbixserver_id=1, zabbixserver=server_a) + ap_assignment_b = SimpleNamespace(hostid='102', zabbixserver_id=2, zabbixserver=server_b) + gw_assignment_a = SimpleNamespace(hostid='201', zabbixserver_id=1, zabbixserver=server_a) + gw_assignment_b = SimpleNamespace(hostid='202', zabbixserver_id=2, zabbixserver=server_b) + + mock_get_children.return_value = [access_point] + mock_get_parents.return_value = [gateway] + + def assignments_for(device): + if device is access_point: + return {1: ap_assignment_a, 2: ap_assignment_b} + if device is gateway: + return {1: gw_assignment_a, 2: gw_assignment_b} + + mock_get_assignments.side_effect = assignments_for + + connections_by_server_name = {} + + def connection_factory(server): + api = MagicMock() + connections_by_server_name[server.name] = api + cm = MagicMock() + cm.__enter__.return_value = api + return cm + + mock_connection.side_effect = connection_factory + + def trigger_for(_api, hostid, description): + if description == 'AP status': + return {'triggerid': f'child-{hostid}', 'description': description, 'dependencies': []} + return {'triggerid': f'parent-{hostid}', 'description': description, 'dependencies': []} + + with patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger', side_effect=trigger_for): + result = sync_device_trigger_dependencies(gateway) + + self.assertEqual(mock_connection.call_count, 2) + connected_server_names = {call.args[0].name for call in mock_connection.call_args_list} + self.assertEqual(connected_server_names, {'Zabbix A', 'Zabbix B'}) + + self.assertEqual(connections_by_server_name['Zabbix A'].trigger.update.call_count, 1) + self.assertEqual(connections_by_server_name['Zabbix B'].trigger.update.call_count, 1) + + connections_by_server_name['Zabbix A'].trigger.update.assert_called_once_with(triggerid='child-101', dependencies=[{'triggerid': 'parent-201'}]) + connections_by_server_name['Zabbix B'].trigger.update.assert_called_once_with(triggerid='child-102', dependencies=[{'triggerid': 'parent-202'}]) + + self.assertEqual(len(result), 2) + self.assertTrue(all(entry['changed'] for entry in result)) + + @patch('nbxsync.utils.trigger_dependency_sync.ZabbixConnection') + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignments') + @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_skips_server_where_no_parent_is_assigned(self, mock_settings, mock_get_children, mock_get_parents, mock_get_assignments, mock_connection): + """Child on {A, B} but parent only on {A} → sync runs on A only, warning logged for B.""" + self.trigger_config.enabled = True + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + + server_a = SimpleNamespace(name='Zabbix A') + server_b = SimpleNamespace(name='Zabbix B') + + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway'), _meta=SimpleNamespace(model_name='device')) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + + ap_assignment_a = SimpleNamespace(hostid='101', zabbixserver_id=1, zabbixserver=server_a) + ap_assignment_b = SimpleNamespace(hostid='102', zabbixserver_id=2, zabbixserver=server_b) + gw_assignment_a = SimpleNamespace(hostid='201', zabbixserver_id=1, zabbixserver=server_a) + + mock_get_children.return_value = [access_point] + mock_get_parents.return_value = [gateway] + + def assignments_for(device): + if device is access_point: + return {1: ap_assignment_a, 2: ap_assignment_b} + if device is gateway: + return {1: gw_assignment_a} + + mock_get_assignments.side_effect = assignments_for + + api = MagicMock() + mock_connection.return_value.__enter__.return_value = api + + def trigger_for(_api, hostid, description): + if description == 'AP status': + return {'triggerid': f'child-{hostid}', 'description': description, 'dependencies': []} + return {'triggerid': f'parent-{hostid}', 'description': description, 'dependencies': []} + + with patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger', side_effect=trigger_for): + with self.assertLogs('nbxsync.utils.trigger_dependency_sync', level='WARNING') as log_ctx: + result = sync_device_trigger_dependencies(gateway) + + mock_connection.assert_called_once_with(server_a) + api.trigger.update.assert_called_once_with(triggerid='child-101', dependencies=[{'triggerid': 'parent-201'}]) + + self.assertEqual(len(result), 1) + self.assertTrue(result[0]['changed']) + + warning_text = '\n'.join(log_ctx.output) + self.assertNotIn('Zabbix A', warning_text) # server A synced successfully; no warning about it + self.assertTrue(any('server' in line.lower() and 'Zabbix B' in line for line in log_ctx.output), f'expected a warning naming server B; got: {log_ctx.output}') + + @patch('nbxsync.utils.trigger_dependency_sync.ZabbixConnection') + @patch('nbxsync.utils.trigger_dependency_sync._sync_prepared_child_dependency') + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignments') + @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_isolates_child_failure_on_same_server(self, mock_settings, mock_get_children, mock_get_parents, mock_get_assignments, mock_sync_prepared, mock_connection): + """Two children on one server, first raises → second still syncs, error logged, no exception propagates.""" + self.trigger_config.enabled = True + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + + zabbixserver = SimpleNamespace(name='Zabbix 1') + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway'), _meta=SimpleNamespace(model_name='device')) + child_1 = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + child_2 = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + child_1_assignment = SimpleNamespace(hostid='101', zabbixserver_id=1, zabbixserver=zabbixserver) + child_2_assignment = SimpleNamespace(hostid='102', zabbixserver_id=1, zabbixserver=zabbixserver) + parent_assignment = SimpleNamespace(hostid='201', zabbixserver_id=1, zabbixserver=zabbixserver) + + mock_get_children.return_value = [child_1, child_2] + mock_get_parents.return_value = [gateway] + + def assignments_for(device): + if device is child_1: + return {1: child_1_assignment} + if device is child_2: + return {1: child_2_assignment} + if device is gateway: + return {1: parent_assignment} + + mock_get_assignments.side_effect = assignments_for + + api = MagicMock() + mock_connection.return_value.__enter__.return_value = api + + child_2_result = {'child': str(child_2), 'parent': str(gateway), 'server': str(zabbixserver), 'changed': True} + mock_sync_prepared.side_effect = [Exception('Zabbix API timeout on child 1'), child_2_result] + + with self.assertLogs('nbxsync.utils.trigger_dependency_sync', level='WARNING') as log_ctx: + result = sync_device_trigger_dependencies(gateway) + + mock_connection.assert_called_once_with(zabbixserver) + self.assertEqual(mock_sync_prepared.call_count, 2) # second child still attempted after first failure + self.assertEqual(result, [child_2_result]) # only successful child appears in result + + log_text = '\n'.join(log_ctx.output) + self.assertIn('Zabbix API timeout on child 1', log_text) + + @patch('nbxsync.utils.trigger_dependency_sync.ZabbixConnection') + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignments') + @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_isolates_failure_of_one_zabbix_server(self, mock_settings, mock_get_children, mock_get_parents, mock_get_assignments, mock_connection): + """One Zabbix server refusing connection doesn't stop sync on the other server.""" + self.trigger_config.enabled = True + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + + server_a = SimpleNamespace(name='Zabbix A') + server_b = SimpleNamespace(name='Zabbix B') + + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway'), _meta=SimpleNamespace(model_name='device')) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + + ap_assignment_a = SimpleNamespace(hostid='101', zabbixserver_id=1, zabbixserver=server_a) + ap_assignment_b = SimpleNamespace(hostid='102', zabbixserver_id=2, zabbixserver=server_b) + gw_assignment_a = SimpleNamespace(hostid='201', zabbixserver_id=1, zabbixserver=server_a) + gw_assignment_b = SimpleNamespace(hostid='202', zabbixserver_id=2, zabbixserver=server_b) + + mock_get_children.return_value = [access_point] + mock_get_parents.return_value = [gateway] + + def assignments_for(device): + if device is access_point: + return {1: ap_assignment_a, 2: ap_assignment_b} + if device is gateway: + return {1: gw_assignment_a, 2: gw_assignment_b} + + mock_get_assignments.side_effect = assignments_for + + server_b_api = MagicMock() + + def connection_factory(server): + if server is server_a: + raise Exception('Zabbix A unreachable') + cm = MagicMock() + cm.__enter__.return_value = server_b_api + return cm + + mock_connection.side_effect = connection_factory + + def trigger_for(_api, hostid, description): + if description == 'AP status': + return {'triggerid': f'child-{hostid}', 'description': description, 'dependencies': []} + return {'triggerid': f'parent-{hostid}', 'description': description, 'dependencies': []} + + with patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger', side_effect=trigger_for): + with self.assertLogs('nbxsync.utils.trigger_dependency_sync', level='WARNING') as log_ctx: + result = sync_device_trigger_dependencies(gateway) + + # Both servers were attempted (mock records the call even when it raises) + self.assertEqual(mock_connection.call_count, 2) + connected_server_names = [call.args[0].name for call in mock_connection.call_args_list] + self.assertIn('Zabbix A', connected_server_names) + self.assertIn('Zabbix B', connected_server_names) + + # Only server B produced an update + server_b_api.trigger.update.assert_called_once_with(triggerid='child-102', dependencies=[{'triggerid': 'parent-202'}]) + + # Only server B contributed to results + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['server'], str(server_b)) + + # Server A's failure was logged + log_text = '\n'.join(log_ctx.output) + self.assertIn('Zabbix A unreachable', log_text) + + def test_token_and_role_normalization_edge_cases(self): + self.assertEqual(_token_set(None), set()) + self.assertEqual(_token_set([' Branch-Gateway ', '', ' ']), {'branch-gateway', 'branch gateway'}) + self.assertEqual(normalized_role_tokens(SimpleNamespace(role=None)), set()) + self.assertEqual(normalized_role_tokens(SimpleNamespace(role=SimpleNamespace(name=None, slug=' Branch-Gateway '))), {'branch-gateway', 'branch gateway'}) + + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_dependency_level_and_managed_descriptions_use_default_settings(self, mock_settings): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + unsupported = SimpleNamespace(role=SimpleNamespace(name='Load Balancer', slug='load-balancer')) + + level_index, level = get_dependency_level(access_point) + + self.assertEqual((level_index, level.name), (0, 'access_point')) + self.assertEqual(get_dependency_level(unsupported), (None, None)) + self.assertEqual(get_managed_trigger_descriptions(), {level.trigger_description for level in self.trigger_config.levels}) + + @patch('nbxsync.utils.trigger_dependency_sync.ZabbixServerAssignment.objects') + @patch('nbxsync.utils.trigger_dependency_sync.ContentType.objects.get_for_model') + def test_get_server_assignments_filters_and_selects_server(self, mock_get_content_type, mock_assignment_objects): + device = SimpleNamespace(pk=42) + content_type = SimpleNamespace(pk=7) + assignment = SimpleNamespace(hostid='1001') + mock_get_content_type.return_value = content_type + queryset = mock_assignment_objects.filter.return_value + queryset.select_related.return_value = [assignment] + + result = get_server_assignments(device) + + self.assertEqual(result, [assignment]) + mock_assignment_objects.filter.assert_called_once_with(assigned_object_type=content_type, assigned_object_id=42, sync_enabled=True, zabbixserver__sync_enabled=True) + queryset.select_related.assert_called_once_with('zabbixserver') + + @patch('nbxsync.utils.trigger_dependency_sync.get_server_assignments') + def test_get_host_assignments_skips_missing_hostids_and_keys_by_server(self, mock_get_server_assignments): + no_hostid = SimpleNamespace(hostid='', zabbixserver_id=1) + first = SimpleNamespace(hostid='101', zabbixserver_id=2) + replacement = SimpleNamespace(hostid='102', zabbixserver_id=2) + mock_get_server_assignments.return_value = [no_hostid, first, replacement] + + result = get_host_assignments(SimpleNamespace()) + + self.assertEqual(result, {2: replacement}) + + @patch('nbxsync.utils.trigger_dependency_sync.Interface.objects') + @patch('nbxsync.utils.trigger_dependency_sync.ContentType.objects.get_for_model') + def test_get_connected_devices_skips_invalid_paths_missing_interfaces_and_duplicate_devices(self, mock_get_content_type, mock_interface_objects): + interface_content_type_id = 10 + remote_device = SimpleNamespace(pk=201) + remote_interface = SimpleNamespace(pk=101, device=remote_device) + remote_without_device = SimpleNamespace(pk=102, device=None) + + interfaces = [ + SimpleNamespace(pk=1), + SimpleNamespace(pk=2, _path=SimpleNamespace(is_complete=False, path=[])), + SimpleNamespace( + pk=3, + _path=SimpleNamespace( + is_complete=True, + path=[['ignored'], ['10:101', '11:999', '10:102', '10:103', '10:101']], + ), + ), + ] + device = SimpleNamespace(interfaces=FakeInterfaceManager(interfaces)) + mock_get_content_type.return_value = SimpleNamespace(id=interface_content_type_id) + mock_interface_objects.filter.return_value.select_related.return_value = [remote_interface, remote_without_device] + + result = get_connected_devices(device) + + self.assertEqual(result, [remote_device]) + mock_interface_objects.filter.assert_called_once_with(pk__in=[101, 102, 103, 101]) + + @patch('nbxsync.utils.trigger_dependency_sync.get_connected_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_get_parent_devices_filters_by_higher_dependency_level(self, mock_settings, mock_get_connected): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + switch = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + peer_switch = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway')) + unsupported = SimpleNamespace(role=SimpleNamespace(name='Load Balancer', slug='load-balancer')) + mock_get_connected.return_value = [access_point, peer_switch, gateway, unsupported] + + self.assertEqual(get_parent_devices(switch), [gateway]) + + unsupported_child = SimpleNamespace(role=SimpleNamespace(name='Load Balancer', slug='load-balancer')) + mock_get_connected.reset_mock() + self.assertEqual(get_parent_devices(unsupported_child, trigger_config=self.trigger_config), []) + mock_get_connected.assert_not_called() + + @patch('nbxsync.utils.trigger_dependency_sync.get_connected_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_get_child_devices_filters_by_lower_dependency_level(self, mock_settings, mock_get_connected): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + switch = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + peer_switch = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway')) + unsupported = SimpleNamespace(role=SimpleNamespace(name='Load Balancer', slug='load-balancer')) + mock_get_connected.return_value = [access_point, peer_switch, gateway, unsupported] + + self.assertEqual(get_child_devices(switch), [access_point]) + + unsupported_parent = SimpleNamespace(role=SimpleNamespace(name='Load Balancer', slug='load-balancer')) + mock_get_connected.reset_mock() + self.assertEqual(get_child_devices(unsupported_parent, trigger_config=self.trigger_config), []) + mock_get_connected.assert_not_called() + + def test_get_host_trigger_returns_first_match_or_none(self): + api = MagicMock() + api.trigger.get.side_effect = [[{'triggerid': '123', 'description': 'AP status'}], []] + + found = get_host_trigger(api, 101, 'AP status') + missing = get_host_trigger(api, '102', 'AP status') + + self.assertEqual(found, {'triggerid': '123', 'description': 'AP status'}) + self.assertIsNone(missing) + api.trigger.get.assert_any_call(hostids=['101'], filter={'description': 'AP status'}, output=['triggerid', 'description'], selectDependencies='extend', expandDescription=True) + + def test_build_dependency_payload_handles_missing_duplicate_and_managed_dependencies(self): + managed_description = self.trigger_config.levels[1].trigger_description + child_trigger = { + 'dependencies': [ + {'description': 'missing trigger id'}, + {'triggerid': '200', 'description': managed_description}, + {'triggerid': '201', 'description': 'Unmanaged'}, + {'triggerid': '201', 'description': 'Duplicate unmanaged'}, + ] + } + + payload = build_dependency_payload(child_trigger, [201, 300, 300], {managed_description}) + + self.assertEqual(payload, [{'triggerid': '201'}, {'triggerid': '300'}]) + self.assertEqual(build_dependency_payload({}, [], set()), []) + + @patch('nbxsync.utils.trigger_dependency_sync._sync_child_dependencies') + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_device_trigger_dependencies_skips_unsupported_objects_and_roles(self, mock_settings, mock_get_children, mock_sync_children): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + unsupported_object = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point'), _meta=SimpleNamespace(model_name='interface')) + unsupported_role = SimpleNamespace(role=SimpleNamespace(name='Load Balancer', slug='load-balancer'), _meta=SimpleNamespace(model_name='device')) + + self.assertEqual(sync_device_trigger_dependencies(unsupported_object), []) + self.assertEqual(sync_device_trigger_dependencies(unsupported_role), []) + mock_get_children.assert_not_called() + mock_sync_children.assert_not_called() + + @patch('nbxsync.utils.trigger_dependency_sync._sync_child_dependencies', return_value=[]) + @patch('nbxsync.utils.trigger_dependency_sync.get_child_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_device_trigger_dependencies_does_not_sync_top_level_device_as_child(self, mock_settings, mock_get_children, mock_sync_children): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + gateway = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway'), _meta=SimpleNamespace(model_name='device')) + child = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + mock_get_children.return_value = [child] + + sync_device_trigger_dependencies(gateway) + + mock_sync_children.assert_called_once_with([child], trigger_config=self.trigger_config) + + @patch('nbxsync.utils.trigger_dependency_sync.ZabbixConnection') + @patch('nbxsync.utils.trigger_dependency_sync._sync_prepared_child_dependency', return_value=None) + @patch('nbxsync.utils.trigger_dependency_sync._prepare_child_dependency_sync') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_child_dependencies_uses_default_settings_and_ignores_empty_results(self, mock_settings, mock_prepare, mock_sync_prepared, mock_connection): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + child = SimpleNamespace(name='child') + server = SimpleNamespace(name='Zabbix') + assignment = SimpleNamespace(zabbixserver_id=1, zabbixserver=server) + prepared = {'child': child, 'child_assignment': assignment} + mock_prepare.return_value = [prepared] + api = MagicMock() + mock_connection.return_value.__enter__.return_value = api + + self.assertEqual(_sync_child_dependencies([child]), []) + mock_prepare.assert_called_once_with(child, trigger_config=self.trigger_config) + mock_sync_prepared.assert_called_once_with(prepared, api, trigger_config=self.trigger_config) + + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignments') + @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_prepare_child_dependency_sync_defensive_exits(self, mock_settings, mock_get_parents, mock_get_assignments): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + unsupported = SimpleNamespace(role=SimpleNamespace(name='Load Balancer', slug='load-balancer')) + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + parent = SimpleNamespace(role=SimpleNamespace(name='Gateway', slug='gateway')) + + self.assertEqual(_prepare_child_dependency_sync(unsupported), []) + + mock_get_parents.return_value = [] + self.assertEqual(_prepare_child_dependency_sync(access_point, trigger_config=self.trigger_config), []) + + mock_get_parents.return_value = [parent] + mock_get_assignments.return_value = {} + self.assertEqual(_prepare_child_dependency_sync(access_point, trigger_config=self.trigger_config), []) + + @patch('nbxsync.utils.trigger_dependency_sync.get_host_assignments') + @patch('nbxsync.utils.trigger_dependency_sync.get_parent_devices') + def test_prepare_child_dependency_sync_skips_parents_without_assignments(self, mock_get_parents, mock_get_assignments): + access_point = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + parent_without_assignment = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + child_assignment = SimpleNamespace(hostid='101', zabbixserver_id=1, zabbixserver=SimpleNamespace(name='Zabbix')) + mock_get_parents.return_value = [parent_without_assignment] + + def assignments_for(device): + if device is access_point: + return {1: child_assignment} + return {} + + mock_get_assignments.side_effect = assignments_for + + self.assertEqual(_prepare_child_dependency_sync(access_point, trigger_config=self.trigger_config), []) + + @patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger', return_value=None) + @patch('nbxsync.utils.trigger_dependency_sync.get_plugin_settings') + def test_sync_prepared_child_dependency_returns_none_when_child_trigger_missing(self, mock_settings, mock_get_trigger): + mock_settings.return_value = SimpleNamespace(trigger_dependencies=self.trigger_config) + child = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + assignment = SimpleNamespace(hostid='101', zabbixserver=SimpleNamespace(name='Zabbix')) + prepared = { + 'child': child, + 'child_level': self.trigger_config.levels[0], + 'child_assignment': assignment, + 'parents_on_server': [], + } + + api = MagicMock() + self.assertIsNone(_sync_prepared_child_dependency(prepared, api)) + mock_get_trigger.assert_called_once_with(api, '101', self.trigger_config.levels[0].trigger_description) + + @patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger') + def test_sync_prepared_child_dependency_skips_missing_parent_triggers(self, mock_get_trigger): + child = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + parent = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + assignment = SimpleNamespace(hostid='101', zabbixserver=SimpleNamespace(name='Zabbix')) + parent_assignment = SimpleNamespace(hostid='201') + child_trigger = {'triggerid': '100', 'description': self.trigger_config.levels[0].trigger_description, 'dependencies': []} + mock_get_trigger.side_effect = [child_trigger, None] + prepared = { + 'child': child, + 'child_level': self.trigger_config.levels[0], + 'child_assignment': assignment, + 'parents_on_server': [(parent, parent_assignment)], + } + + self.assertIsNone(_sync_prepared_child_dependency(prepared, MagicMock(), trigger_config=self.trigger_config)) + + @patch('nbxsync.utils.trigger_dependency_sync.get_host_trigger') + def test_sync_prepared_child_dependency_avoids_update_when_dependency_is_already_correct(self, mock_get_trigger): + child = SimpleNamespace(role=SimpleNamespace(name='Access Point', slug='access-point')) + parent = SimpleNamespace(role=SimpleNamespace(name='Switch', slug='switch')) + server = SimpleNamespace(name='Zabbix') + assignment = SimpleNamespace(hostid='101', zabbixserver=server) + parent_assignment = SimpleNamespace(hostid='201') + parent_description = self.trigger_config.levels[1].trigger_description + child_trigger = { + 'triggerid': '100', + 'description': self.trigger_config.levels[0].trigger_description, + 'dependencies': [{'triggerid': '200', 'description': parent_description}], + } + parent_trigger = {'triggerid': '200', 'description': parent_description, 'dependencies': []} + mock_get_trigger.side_effect = [child_trigger, parent_trigger] + prepared = { + 'child': child, + 'child_level': self.trigger_config.levels[0], + 'child_assignment': assignment, + 'parents_on_server': [(parent, parent_assignment)], + } + api = MagicMock() + + result = _sync_prepared_child_dependency(prepared, api, trigger_config=self.trigger_config) + + self.assertEqual(result, {'child': str(child), 'parent': str(parent), 'server': str(server), 'changed': False}) + api.trigger.update.assert_not_called() + class FakeInterfaceManager: def __init__(self, interfaces): diff --git a/nbxsync/utils/trigger_dependency_sync.py b/nbxsync/utils/trigger_dependency_sync.py index 759865aa..18f66717 100644 --- a/nbxsync/utils/trigger_dependency_sync.py +++ b/nbxsync/utils/trigger_dependency_sync.py @@ -65,9 +65,14 @@ def get_server_assignments(device): ) -def get_host_assignment(device): - assignments = [assignment for assignment in get_server_assignments(device) if assignment.hostid] - return assignments[0] if assignments else None +def get_host_assignments(device): + """All Zabbix server assignments with a hostid, keyed by zabbixserver_id.""" + assignments_by_server = {} + for assignment in get_server_assignments(device): + if not assignment.hostid: + continue + assignments_by_server[assignment.zabbixserver_id] = assignment + return assignments_by_server def get_connected_devices(device): @@ -197,21 +202,27 @@ def _sync_child_dependencies(children, trigger_config=None): prepared_by_server = {} for child in children: - prepared = _prepare_child_dependency_sync(child, trigger_config=trigger_config) - if not prepared: - continue - - server_id = prepared['child_assignment'].zabbixserver_id - prepared_by_server.setdefault(server_id, []).append(prepared) + for prepared in _prepare_child_dependency_sync(child, trigger_config=trigger_config): + server_id = prepared['child_assignment'].zabbixserver_id + prepared_by_server.setdefault(server_id, []).append(prepared) results = [] - for prepared_children in prepared_by_server.values(): - zabbixserver = prepared_children[0]['child_assignment'].zabbixserver - with ZabbixConnection(zabbixserver) as api: - for prepared in prepared_children: - result = _sync_prepared_child_dependency(prepared, api, trigger_config=trigger_config) - if result: - results.append(result) + for prepared_group in prepared_by_server.values(): + zabbixserver = prepared_group[0]['child_assignment'].zabbixserver + try: + with ZabbixConnection(zabbixserver) as api: + for prepared in prepared_group: + try: + result = _sync_prepared_child_dependency(prepared, api, trigger_config=trigger_config) + except Exception: + logger.exception('Trigger dependency sync failed for %s on %s; continuing with remaining children.', prepared['child'], zabbixserver) + continue + + if result: + results.append(result) + except Exception: + logger.exception('Trigger dependency sync could not open a connection to %s; skipping this server.', zabbixserver) + continue return results @@ -221,32 +232,59 @@ def _prepare_child_dependency_sync(child, trigger_config=None): child_level_index, child_level = get_dependency_level(child, trigger_config=trigger_config) if child_level_index is None: logger.debug('Skipping dependency sync for unsupported child role on %s.', child) - return None + return [] parent_devices = get_parent_devices(child, trigger_config=trigger_config) if not parent_devices: logger.warning('No higher-level connected parent found for %s; skipping dependency sync.', child) - return None + return [] - child_assignment = get_host_assignment(child) - if not child_assignment: + child_assignments = get_host_assignments(child) + if not child_assignments: logger.warning('No Zabbix host assignment with hostid found for %s.', child) - return None + return [] - return { - 'child': child, - 'child_level': child_level, - 'parent_devices': parent_devices, - 'child_assignment': child_assignment, - } + parent_assignments_by_device = [] + for parent in parent_devices: + parent_assignments = get_host_assignments(parent) + if not parent_assignments: + logger.warning('No Zabbix host assignment with hostid found for parent %s.', parent) + continue + parent_assignments_by_device.append((parent, parent_assignments)) + + if not parent_assignments_by_device: + return [] + + prepared_items = [] + for server_id, child_assignment in child_assignments.items(): + parents_on_server = [] + for parent, parent_assignments in parent_assignments_by_device: + parent_assignment = parent_assignments.get(server_id) + if parent_assignment is not None: + parents_on_server.append((parent, parent_assignment)) + + if not parents_on_server: + logger.warning('No connected parent of %s is assigned to Zabbix server %s; skipping.', child, child_assignment.zabbixserver) + continue + + prepared_items.append( + { + 'child': child, + 'child_level': child_level, + 'child_assignment': child_assignment, + 'parents_on_server': parents_on_server, + } + ) + + return prepared_items def _sync_prepared_child_dependency(prepared, api, trigger_config=None): trigger_config = trigger_config or get_plugin_settings().trigger_dependencies child = prepared['child'] child_level = prepared['child_level'] - parent_devices = prepared['parent_devices'] child_assignment = prepared['child_assignment'] + parents_on_server = prepared['parents_on_server'] child_trigger = get_host_trigger(api, child_assignment.hostid, child_level.trigger_description) if not child_trigger: @@ -255,21 +293,9 @@ def _sync_prepared_child_dependency(prepared, api, trigger_config=None): parent_triggers = [] parent_names = [] - for parent in parent_devices: - _, parent_level = get_dependency_level(parent, trigger_config=trigger_config) - parent_assignment = get_host_assignment(parent) - if not parent_assignment: - logger.warning('No Zabbix host assignment with hostid found for parent %s.', parent) - continue - - if child_assignment.zabbixserver_id != parent_assignment.zabbixserver_id: - logger.warning( - 'Skipping dependency sync for %s: child and parent %s are assigned to different Zabbix servers.', - child, - parent, - ) - continue + for parent, parent_assignment in parents_on_server: + _, parent_level = get_dependency_level(parent, trigger_config=trigger_config) parent_trigger = get_host_trigger(api, parent_assignment.hostid, parent_level.trigger_description) if not parent_trigger: logger.warning('No "%s" trigger found for %s.', parent_level.trigger_description, parent) @@ -289,10 +315,11 @@ def _sync_prepared_child_dependency(prepared, api, trigger_config=None): current_ids = {dep.get('triggerid') for dep in child_trigger.get('dependencies', []) if dep.get('triggerid')} desired_ids = {dep.get('triggerid') for dep in dependency_payload} + server_label = str(child_assignment.zabbixserver) if current_ids == desired_ids: - logger.info('Dependency already correct: %s -> %s.', child, ', '.join(parent_names)) - return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': False} + logger.info('Dependency already correct on %s: %s -> %s.', server_label, child, ', '.join(parent_names)) + return {'child': str(child), 'parent': ', '.join(parent_names), 'server': server_label, 'changed': False} api.trigger.update(triggerid=child_trigger['triggerid'], dependencies=dependency_payload) - logger.info('Updated dependency: %s -> %s.', child, ', '.join(parent_names)) - return {'child': str(child), 'parent': ', '.join(parent_names), 'changed': True} + logger.info('Updated dependency on %s: %s -> %s.', server_label, child, ', '.join(parent_names)) + return {'child': str(child), 'parent': ', '.join(parent_names), 'server': server_label, 'changed': True} From 7c0b534093bfd37f8102e9ef3b91668fded355cf Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 23:13:45 +0200 Subject: [PATCH 33/40] (1) Reject role tokens that are only a whitespace; (2) duplicate trigger_descriptions and (3) roles overlapping between levels --- nbxsync/settings.py | 73 ++++++++++++++++++--------- nbxsync/tests/test_plugin_settings.py | 42 ++++++++++++++- 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/nbxsync/settings.py b/nbxsync/settings.py index 1a52520b..4d407ff7 100644 --- a/nbxsync/settings.py +++ b/nbxsync/settings.py @@ -1,7 +1,7 @@ from typing import Dict, List, Tuple from django.apps import apps -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from nbxsync.choices.syncsot import SyncSOT from nbxsync.choices.zabbixstatus import ZabbixHostStatus @@ -9,6 +9,18 @@ __all__ = ('PluginSettingsModel', 'TriggerDependencyConfig', 'TriggerDependencyLevelConfig') +def _normalize_role_tokens(values): + """Normalize role tokens using the same semantics as dependency matching.""" + tokens = set() + + for value in values: + normalized = value.strip().lower() + tokens.add(normalized) + tokens.add(normalized.replace('-', ' ')) + + return tokens + + class SoTConfig(BaseModel): proxygroup: SyncSOT = SyncSOT.NETBOX proxy: SyncSOT = SyncSOT.NETBOX @@ -56,16 +68,23 @@ class TriggerDependencyLevelConfig(BaseModel): trigger_description: str @field_validator('name', 'trigger_description', mode='before') - def validate_non_empty_string(cls, v: str) -> str: + def validate_non_empty_string(cls, v): if not isinstance(v, str) or not v.strip(): raise ValueError('Value must be a non-empty string') return v.strip() @field_validator('roles', mode='before') - def validate_role_tokens(cls, v: List[str]) -> List[str]: + def validate_role_tokens(cls, v): if not isinstance(v, list) or not v: raise ValueError('Role tokens must be a non-empty list') - return v + + cleaned = [] + for role in v: + if not isinstance(role, str) or not role.strip(): + raise ValueError('Role tokens must be non-empty strings') + cleaned.append(role.strip()) + + return cleaned class TriggerDependencyConfig(BaseModel): @@ -74,35 +93,39 @@ class TriggerDependencyConfig(BaseModel): enabled: bool = Field(default=False) levels: List[TriggerDependencyLevelConfig] = Field( default_factory=lambda: [ - TriggerDependencyLevelConfig( - name='access_point', - roles=['access point', 'access-point', 'ap'], - trigger_description='AP status', - ), - TriggerDependencyLevelConfig( - name='switch', - roles=['switch', 'sw'], - trigger_description='Switch status', - ), - TriggerDependencyLevelConfig( - name='gateway', - roles=[ - 'gateway', - 'gw', - 'firewall', - 'router', - ], - trigger_description='Gateway status', - ), + TriggerDependencyLevelConfig(name='access_point', roles=['access point', 'access-point', 'ap'], trigger_description='AP status'), + TriggerDependencyLevelConfig(name='switch', roles=['switch', 'sw'], trigger_description='Switch status'), + TriggerDependencyLevelConfig(name='gateway', roles=['gateway', 'gw', 'firewall', 'router'], trigger_description='Gateway status'), ] ) @field_validator('levels', mode='before') - def validate_levels(cls, v: List[TriggerDependencyLevelConfig]) -> List[TriggerDependencyLevelConfig]: + def validate_levels(cls, v): if not isinstance(v, list) or not v: raise ValueError('Dependency levels must be a non-empty list') return v + @model_validator(mode='after') + def validate_unique_levels(self): + descriptions = {} + roles = {} + + for level in self.levels: + previous_level = descriptions.get(level.trigger_description) + if previous_level is not None: + raise ValueError(f'Trigger description {level.trigger_description!r} is used by both dependency levels {previous_level!r} and {level.name!r}') + + descriptions[level.trigger_description] = level.name + + for role in _normalize_role_tokens(level.roles): + previous_level = roles.get(role) + if previous_level is not None: + raise ValueError(f'Role token {role!r} overlaps between dependency levels {previous_level!r} and {level.name!r}') + + roles[role] = level.name + + return self + class PluginSettingsModel(BaseModel): sot: SoTConfig = SoTConfig() diff --git a/nbxsync/tests/test_plugin_settings.py b/nbxsync/tests/test_plugin_settings.py index 1d062b2a..51c81b8e 100644 --- a/nbxsync/tests/test_plugin_settings.py +++ b/nbxsync/tests/test_plugin_settings.py @@ -1,13 +1,14 @@ from unittest.mock import MagicMock, patch from django.test import TestCase + from pydantic import ValidationError from nbxsync.choices.syncsot import SyncSOT from nbxsync.settings import ( + BackgroundSyncConfig, PluginSettingsModel, SNMPConfig, - BackgroundSyncConfig, TriggerDependencyConfig, TriggerDependencyLevelConfig, get_plugin_settings, @@ -109,3 +110,42 @@ def test_trigger_dependency_levels_validation(self): with self.assertRaises(ValidationError) as ctx: TriggerDependencyConfig(levels=[]) self.assertIn('Dependency levels must be a non-empty list', str(ctx.exception)) + + def test_trigger_dependency_level_rejects_blank_role(self): + with self.assertRaises(ValidationError) as ctx: + TriggerDependencyLevelConfig(name='switch', roles=['switch', ' '], trigger_description='Switch status') + + self.assertIn('Role tokens must be non-empty strings', str(ctx.exception)) + + def test_trigger_dependency_rejects_duplicate_trigger_descriptions(self): + with self.assertRaises(ValidationError) as ctx: + TriggerDependencyConfig( + levels=[ + TriggerDependencyLevelConfig(name='switch', roles=['switch'], trigger_description='Device status'), + TriggerDependencyLevelConfig(name='gateway', roles=['gateway'], trigger_description='Device status'), + ] + ) + + self.assertIn("Trigger description 'Device status' is used by both", str(ctx.exception)) + + def test_trigger_dependency_rejects_overlapping_roles(self): + with self.assertRaises(ValidationError) as ctx: + TriggerDependencyConfig( + levels=[ + TriggerDependencyLevelConfig(name='switch', roles=['switch'], trigger_description='Switch status'), + TriggerDependencyLevelConfig(name='gateway', roles=['gateway', 'SWITCH'], trigger_description='Gateway status'), + ] + ) + + self.assertIn("Role token 'switch' overlaps between dependency levels", str(ctx.exception)) + + def test_trigger_dependency_rejects_normalized_role_overlap(self): + with self.assertRaises(ValidationError) as ctx: + TriggerDependencyConfig( + levels=[ + TriggerDependencyLevelConfig(name='switch', roles=['core-switch'], trigger_description='Switch status'), + TriggerDependencyLevelConfig(name='gateway', roles=['core switch'], trigger_description='Gateway status'), + ] + ) + + self.assertIn("Role token 'core switch' overlaps between dependency levels", str(ctx.exception)) From 3c194d83b2b91d2f8dd3045f0d3695a11fdcfd05 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 23:17:21 +0200 Subject: [PATCH 34/40] Add changelog --- docs/changelog.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 60f87675..dfac4107 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -106,7 +106,8 @@ None ### New features -- +- Allow configuring a custom hostname and displayname for Zabbix, thanks @bhinz ([#87]) +- Implemented 'Trigger dependency sync', thanks to @benjofalanga ([#100]) ### Bug fixes @@ -114,7 +115,14 @@ None - Fixed issue where local_address was cleared, even when the proxy is part of a ProxyGroup ([#91]) - Removed duplicate line in ProxySync ([#91]) - Fixed typo (`acept` vs `accept`) in ProxySync ([#91]) - +- Two bugs in syncmaintenance: missing zabbixserver attribute + invalid "day" parameter for non-MONTHLY timeperiods ([#107]) +- Fixed typo in SyncMaintenceJob +- Fixed issue where only 1 ZabbixServer would be synced +- Fixed the resolution of the pathlabel of a Zabbix Configuration Group +- Fixed issues with HostEvent when multiple ZabbixServers are configured +- Replaced 'gettext' with 'gettext_lazy' + + ### Breaking changes - Dropped support for NetBox < 4.2.6 in order to support NetBox 4.6.X ([#98]) @@ -145,7 +153,10 @@ None [#79]: https://github.com/OpensourceICTSolutions/nbxsync/issues/79 [#81]: https://github.com/OpensourceICTSolutions/nbxsync/issues/81 [#86]: https://github.com/OpensourceICTSolutions/nbxsync/issues/86 +[#87]: https://github.com/OpensourceICTSolutions/nbxsync/issues/87 [#88]: https://github.com/OpensourceICTSolutions/nbxsync/issues/88 [#90]: https://github.com/OpensourceICTSolutions/nbxsync/issues/90 [#91]: https://github.com/OpensourceICTSolutions/nbxsync/issues/91 [#98]: https://github.com/OpensourceICTSolutions/nbxsync/issues/98 +[#100]: https://github.com/OpensourceICTSolutions/nbxsync/issues/100 +[#107]: https://github.com/OpensourceICTSolutions/nbxsync/issues/107 From cdc0c06d03b19fbbd85c94bf739a9de1542e42d4 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 23:21:00 +0200 Subject: [PATCH 35/40] Update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 65672335..ae976bd7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ This plugin bridges the gap between network/source-of-truth documentation and in | Release | Minimum NetBox Version | Maximum NetBox Version | | ------- | ---------------------- | ---------------------- | +| 1.0.5 | 4.2.6 | 4.6.x | +| 1.0.4 | 4.2.0 | 4.5.x | | 1.0.3 | 4.2.0 | 4.5.x | | 1.0.2 | 4.2.0 | 4.5.x | | 1.0.1 | 4.2.0 | 4.4.x | From d42c344e52cd92a6a38847c4cbc4f20994f56488 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 23:31:31 +0200 Subject: [PATCH 36/40] Ruff cleanup --- docs/installation.md | 14 +++++++------- docs/zabbixconfigurationgroup.md | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 9bd258f2..629de65e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,7 @@ If you want to change the default configuration, can add the following configura ```python title="netbox/configuration.py" PLUGINS = ['nbxsync'] PLUGINS_CONFIG = { - "nbxsync": { + 'nbxsync': { 'sot': { 'proxygroup': 'netbox', 'proxy': 'netbox', @@ -89,19 +89,19 @@ PLUGINS_CONFIG = { 'backgroundsync': { 'objects': { 'enabled': True, - 'interval': 60, # 1 hour + 'interval': 60, # 1 hour }, 'templates': { 'enabled': True, - 'interval': 1440, # 24 hours + 'interval': 1440, # 24 hours }, 'proxies': { 'enabled': True, - 'interval': 1440, # 24 hours + 'interval': 1440, # 24 hours }, 'maintenance': { 'enabled': True, - 'interval': 15, # 15 minutes + 'interval': 15, # 15 minutes }, }, 'no_alerting_tag': 'NO_ALERTING', @@ -110,8 +110,8 @@ PLUGINS_CONFIG = { 'attach_objtag': True, 'objtag_type': 'nb_type', 'objtag_id': 'nb_id', - 'custom_field_hostname':'', - 'custom_field_display_name':'', + 'custom_field_hostname': '', + 'custom_field_display_name': '', } } ``` diff --git a/docs/zabbixconfigurationgroup.md b/docs/zabbixconfigurationgroup.md index 46fd39c9..a0e9cfec 100644 --- a/docs/zabbixconfigurationgroup.md +++ b/docs/zabbixconfigurationgroup.md @@ -14,7 +14,7 @@ Think of it as a "profile": you attach Zabbix servers, templates, tags, host gro ```python class ZabbixConfigurationGroup(NetBoxModel): - name = CharField(max_length=512) + name = CharField(max_length=512) description = CharField(max_length=1024, blank=True) ``` @@ -42,11 +42,11 @@ The important distinction: this is **not** about Zabbix config per se — it is The existing nbxsync assignment models (`ZabbixServerAssignment`, `ZabbixTemplateAssignment`, `ZabbixTagAssignment`, `ZabbixHostgroupAssignment`, `ZabbixMacroAssignment`, `ZabbixHostInterface`) all have two distinct modes of operation: -| Mode | `assigned_object` points to | `zabbixconfigurationgroup` field | -|---|---|---| -| **Direct assignment** | A Device / VM / VDC | `NULL` | -| **Group-level definition** | A `ZabbixConfigurationGroup` | `NULL` | -| **Group-cloned copy** | A Device / VM / VDC | FK to the originating group | +| Mode | `assigned_object` points to | `zabbixconfigurationgroup` field | +| -------------------------- | ---------------------------- | -------------------------------- | +| **Direct assignment** | A Device / VM / VDC | `NULL` | +| **Group-level definition** | A `ZabbixConfigurationGroup` | `NULL` | +| **Group-cloned copy** | A Device / VM / VDC | FK to the originating group | The `zabbixconfigurationgroup` FK on each assignment model is the "provenance" marker: a non-null value means this record was **created by the group propagation system**, not by a human directly. This matters for the "don't overwrite manual assignments" logic (described below). From b34a8f1b1dc98737702a29ec220673a86db2ac21 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Mon, 10 Aug 2026 23:34:17 +0200 Subject: [PATCH 37/40] Add AI Policy --- AI_POLICY.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 AI_POLICY.md diff --git a/AI_POLICY.md b/AI_POLICY.md new file mode 100644 index 00000000..8b21f1b2 --- /dev/null +++ b/AI_POLICY.md @@ -0,0 +1,33 @@ +# nbxSync - AI Policy + +We support using AI (i.e., LLMs) as tools when contributing to nbxSync. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to nbxSync. + +Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted. + +## Autonomous agents + +**We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates. + +## Communication on issues, pull requests, and code reviews + +We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output. + +If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy. + +**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own. + +If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets. + +## Non-native English speakers + +We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable. + +## Code and documentation contributions + +AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI. + +All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review. + +## Enforcement + +Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to nbxSync. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss. \ No newline at end of file From ebb15f80618b06c06f82d683ab0541ffe20bc513 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Tue, 11 Aug 2026 08:22:03 +0200 Subject: [PATCH 38/40] Update query counts file --- nbxsync/tests/query_counts.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nbxsync/tests/query_counts.json b/nbxsync/tests/query_counts.json index a0459c74..0f69de06 100644 --- a/nbxsync/tests/query_counts.json +++ b/nbxsync/tests/query_counts.json @@ -15,4 +15,4 @@ "zabbixtagassignment:list_objects_with_permission": 20, "zabbixtemplate:list_objects_with_permission": 19, "zabbixtemplateassignment:list_objects_with_permission": 20 -} \ No newline at end of file +} From 0599e9662245d7ffb01aea8f4d99850f406d9f22 Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Tue, 11 Aug 2026 09:35:34 +0200 Subject: [PATCH 39/40] Pin CI to specific NB version and not main --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62b94d6e..6349d9b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,11 +49,12 @@ jobs: steps: - name: Check out NetBox - uses: actions/checkout@v5 - with: - repository: "netbox-community/netbox" - ref: "main" - path: "netbox" + uses: actions/checkout@v5 + with: + repository: "netbox-community/netbox" + ref: "v4.6.7" + path: "netbox" + - name: Check out repo uses: actions/checkout@v5 From 0cffa18401f301d4dc7f8342bce3c58c2fb7e3cd Mon Sep 17 00:00:00 2001 From: bvbaekel Date: Tue, 11 Aug 2026 09:43:42 +0200 Subject: [PATCH 40/40] Fix yaml --- .github/workflows/ci.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6349d9b9..a08f8b14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,12 +49,11 @@ jobs: steps: - name: Check out NetBox - uses: actions/checkout@v5 - with: - repository: "netbox-community/netbox" - ref: "v4.6.7" - path: "netbox" - + uses: actions/checkout@v5 + with: + repository: "netbox-community/netbox" + ref: "v4.6.7" + path: "netbox" - name: Check out repo uses: actions/checkout@v5