diff --git a/docs/changelog.md b/docs/changelog.md index 60f87675..b352d95f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -102,6 +102,13 @@ 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]) + +## [Unreleased] + +### New features + +- Added Site, SiteGroup, and Region as assignment targets with inheritance. Assignments (proxy, templates, tags, hostgroups, interfaces, inventory) made at the Site/SiteGroup/Region level are now inherited by all devices and VMs at that site or below. +- Added `ZabbixTemplateRule` model for regex-based template, hostgroup, and tag assignment by platform name. Includes a 2-second timeout to prevent catastrophic backtracking (ReDoS). ## [1.0.5] - Minor update ### New features diff --git a/docs/configuration.md b/docs/configuration.md index 8dc66c9c..789f1300 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,15 @@ The plugin is configuration to do exactly what you want, by means of the plugin 'snmp_privpass': '{$SNMP_PRIVPASS}', }, 'inheritance_chain': [ + ['device', 'site'], + ['site'], + ['site', 'group'], + ['group', 'parent'], + ['site', 'group', 'parent'], + ['site', 'region'], + ['region'], + ['region', 'parent'], + ['cluster', 'site'], ['device'], ['role'], ['device', 'role'], @@ -84,6 +93,48 @@ The plugin is configuration to do exactly what you want, by means of the plugin } ``` +## Inheritance Chain + +The `inheritance_chain` setting defines which NetBox objects are traversed when resolving Zabbix assignments. Assignments (templates, tags, hostgroups, macros, proxy/server, interfaces, inventory) made on any object in the chain are inherited by the device or VM being synced, with direct assignments taking priority. + +### Site, SiteGroup, and Region Inheritance + +The chain includes paths for site-level inheritance, allowing assignments made at the `Site`, `SiteGroup`, or `Region` level to be inherited by all devices and VMs at that site or below: + +| Path | Description | +|------|-------------| +| `['device', 'site']` | The device's site | +| `['site']` | Site (direct) | +| `['site', 'group']` | The site's SiteGroup | +| `['group', 'parent']` | Parent SiteGroup (traverses the full hierarchy) | +| `['site', 'group', 'parent']` | Full path: device → site → group → parent group | +| `['site', 'region']` | The site's region | +| `['region']` | Region (direct) | +| `['region', 'parent']` | Parent region (traverses the full hierarchy) | +| `['cluster', 'site']` | The cluster's site (for VMs) | + +For example, assigning a `ZabbixServerAssignment` (proxy) to a `SiteGroup` means every device at every site in that SiteGroup inherits the proxy — no per-device assignment needed. + +## Zabbix Template Rules + +`ZabbixTemplateRule` allows automatic template assignment based on the device's or VM's platform name. Each rule has a regex pattern that is matched (case-insensitive) against the platform name. When a rule matches, the configured Zabbix template is assigned to the host. + +Rules are resolved after all direct and inherited assignments, so explicit `ZabbixTemplateAssignment` objects always take priority. + +Each rule can optionally also assign a hostgroup and a tag when the pattern matches — useful for OS-family grouping (e.g. a `Windows` rule that assigns the `Windows by Zabbix agent` template, a `Windows` hostgroup, and an `os_family=Windows` tag in one rule). + +| Field | Description | +|-------|-------------| +| `name` | Human-readable name | +| `pattern` | Regex pattern matched against platform name (case-insensitive) | +| `zabbixtemplate` | Template assigned when the rule matches | +| `zabbixhostgroup` | Optional hostgroup assigned on match (nullable) | +| `zabbixtag` | Optional tag assigned on match (nullable) | +| `enabled` | Enable/disable rule without deleting it | +| `priority` | Lower value = higher priority (rules evaluated in order) | + +Patterns are validated at save time (`re.compile`). A timeout (2 seconds) protects against catastrophic backtracking during matching. + ## Configuration values ### Source of Truth diff --git a/nbxsync/__init__.py b/nbxsync/__init__.py index fc83c3b3..e000cd8f 100644 --- a/nbxsync/__init__.py +++ b/nbxsync/__init__.py @@ -76,6 +76,13 @@ class nbxSync(PluginConfig): 'snmp_privpass': '{$SNMP_PRIVPASS}', }, 'inheritance_chain': [ + ['device', 'site'], + ['site'], + ['site', 'group'], + ['site', 'region'], + ['region'], + ['region', 'parent'], + ['cluster', 'site'], ['device'], ['role'], ['device', 'role'], diff --git a/nbxsync/constants/assignment_models.py b/nbxsync/constants/assignment_models.py index 69cf9e29..e8d1e384 100644 --- a/nbxsync/constants/assignment_models.py +++ b/nbxsync/constants/assignment_models.py @@ -3,6 +3,8 @@ ASSIGNMENT_MODELS = Q( Q(app_label='dcim', model='device') | Q(app_label='dcim', model='virtualdevicecontext') + | Q(app_label='dcim', model='site') + | Q(app_label='dcim', model='sitegroup') | Q(app_label='dcim', model='manufacturer') | Q(app_label='dcim', model='devicerole') | Q(app_label='dcim', model='devicetype') diff --git a/nbxsync/constants/assignment_type_to_field.py b/nbxsync/constants/assignment_type_to_field.py index 90b9f58a..07eb16fe 100644 --- a/nbxsync/constants/assignment_type_to_field.py +++ b/nbxsync/constants/assignment_type_to_field.py @@ -1,4 +1,4 @@ -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.models import ZabbixHostgroup, ZabbixServer, ZabbixTag, ZabbixTemplate, ZabbixConfigurationGroup @@ -14,6 +14,9 @@ Cluster: 'cluster', ClusterType: 'clustertype', VirtualDeviceContext: 'virtualdevicecontext', + Site: 'site', + SiteGroup: 'sitegroup', + Region: 'region', ZabbixConfigurationGroup: 'zabbixconfigurationgroup', } @@ -28,6 +31,9 @@ Device: 'device', VirtualMachine: 'virtualmachine', VirtualDeviceContext: 'virtualdevicecontext', + Site: 'site', + SiteGroup: 'sitegroup', + Region: 'region', ZabbixHostgroup: 'zabbixhostgroup', } diff --git a/nbxsync/constants/path_labels.py b/nbxsync/constants/path_labels.py index 20a1139f..fbc1a662 100644 --- a/nbxsync/constants/path_labels.py +++ b/nbxsync/constants/path_labels.py @@ -1,4 +1,11 @@ PATH_LABELS = { + ('device', 'site'): 'Site', + ('site',): 'Site', + ('site', 'group'): 'Site Group', + ('site', 'region'): 'Region', + ('region',): 'Region', + ('region', 'parent'): 'Region', + ('cluster', 'site'): 'Site', ('device_type',): 'Device Type', ('device_type', 'manufacturer'): 'Manufacturer', ('zabbixconfigurationgroup'): 'Zabbix Configuration Group', diff --git a/nbxsync/filtersets/__init__.py b/nbxsync/filtersets/__init__.py index 9af1a51b..476bc851 100644 --- a/nbxsync/filtersets/__init__.py +++ b/nbxsync/filtersets/__init__.py @@ -18,3 +18,5 @@ from .zabbixmaintenancetagassignment import * from .zabbixconfigurationgroup import * from .zabbixconfigurationgroupassignment import * + +from .zabbixtemplaterule import * \ No newline at end of file diff --git a/nbxsync/filtersets/zabbixtemplaterule.py b/nbxsync/filtersets/zabbixtemplaterule.py new file mode 100644 index 00000000..93bfa2cc --- /dev/null +++ b/nbxsync/filtersets/zabbixtemplaterule.py @@ -0,0 +1,24 @@ +from django.db.models import Q +from django_filters import CharFilter + +from netbox.filtersets import NetBoxModelFilterSet + +from nbxsync.models import ZabbixTemplateRule + +__all__ = ('ZabbixTemplateRuleFilterSet',) + + +class ZabbixTemplateRuleFilterSet(NetBoxModelFilterSet): + q = CharFilter(method='search', label='Search') + name = CharFilter(lookup_expr='icontains') + description = CharFilter(lookup_expr='icontains') + pattern = CharFilter(lookup_expr='icontains') + + class Meta: + model = ZabbixTemplateRule + fields = ('id', 'name', 'description', 'pattern', 'enabled', 'priority') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value) | Q(pattern__icontains=value)).distinct() diff --git a/nbxsync/forms/__init__.py b/nbxsync/forms/__init__.py index 938b3478..ff4402e0 100644 --- a/nbxsync/forms/__init__.py +++ b/nbxsync/forms/__init__.py @@ -20,3 +20,5 @@ from .zabbixmaintenancetagassignment import * from .zabbixconfigurationgroup import * from .zabbixconfigurationgroupassignment import * + +from .zabbixtemplaterule import * \ No newline at end of file diff --git a/nbxsync/forms/zabbixhostgroupassignment.py b/nbxsync/forms/zabbixhostgroupassignment.py index 04c59869..3dfca4f6 100644 --- a/nbxsync/forms/zabbixhostgroupassignment.py +++ b/nbxsync/forms/zabbixhostgroupassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -25,7 +25,10 @@ class ZabbixHostgroupAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -42,6 +45,9 @@ class ZabbixHostgroupAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -53,6 +59,7 @@ class Meta: fields = ( 'zabbixhostgroup', 'device', + 'virtualdevicecontext', 'virtualmachine', 'cluster', 'clustertype', @@ -61,6 +68,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/forms/zabbixhostinventory.py b/nbxsync/forms/zabbixhostinventory.py index 65ab3ac5..01506ae8 100644 --- a/nbxsync/forms/zabbixhostinventory.py +++ b/nbxsync/forms/zabbixhostinventory.py @@ -6,8 +6,9 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext -from virtualization.models import VirtualMachine +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region +from virtualization.models import VirtualMachine, Cluster, ClusterType +from nbxsync.models import ZabbixConfigurationGroup from nbxsync.choices import ZabbixHostInventoryModeChoices from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -75,6 +76,16 @@ class ZabbixHostInventoryForm(NetBoxModelForm): device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False, selector=True, label=_('Device')) virtualdevicecontext = DynamicModelChoiceField(queryset=VirtualDeviceContext.objects.all(), required=False, selector=True, label=_('Virtual Device Context')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) + platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) + devicerole = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False, selector=True, label=_('Device Role')) + devicetype = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False, selector=True, label=_('Device Type')) + manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) + cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) + clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) + zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) fieldsets = ( FieldSet( @@ -178,8 +189,18 @@ class ZabbixHostInventoryForm(NetBoxModelForm): FieldSet( TabbedGroups( FieldSet('device', name=_('Device')), - FieldSet('virtualdevicecotext', name=_('Virtual Device Context')), + FieldSet('virtualdevicecontext', name=_('Virtual Device Context')), FieldSet('virtualmachine', name=_('Virtual Machine')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), + FieldSet('platform', name=_('Platform')), + FieldSet('devicerole', name=_('Device Role')), + FieldSet('devicetype', name=_('Device Type')), + FieldSet('manufacturer', name=_('Manufacturer')), + FieldSet('cluster', name=_('Cluster')), + FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('zabbixconfigurationgroup', name=_('Config Group')), ), name=_('Assignment'), ), @@ -260,7 +281,18 @@ class Meta: 'name', 'notes', 'device', + 'virtualdevicecontext', 'virtualmachine', + 'site', + 'sitegroup', + 'region', + 'platform', + 'devicerole', + 'devicetype', + 'manufacturer', + 'cluster', + 'clustertype', + 'zabbixconfigurationgroup', ) @property diff --git a/nbxsync/forms/zabbixmacroassignment.py b/nbxsync/forms/zabbixmacroassignment.py index 6cb49d3b..596ed6c0 100644 --- a/nbxsync/forms/zabbixmacroassignment.py +++ b/nbxsync/forms/zabbixmacroassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD @@ -26,6 +26,9 @@ class ZabbixMacroAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -43,6 +46,9 @@ class ZabbixMacroAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -66,6 +72,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/forms/zabbixserverassignment.py b/nbxsync/forms/zabbixserverassignment.py index 57c31d79..567dccf0 100644 --- a/nbxsync/forms/zabbixserverassignment.py +++ b/nbxsync/forms/zabbixserverassignment.py @@ -7,7 +7,7 @@ from netbox.forms import NetBoxModelImportForm, NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField, CSVModelChoiceField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext +from dcim.models import Device, VirtualDeviceContext, Site, SiteGroup, Region from virtualization.models import VirtualMachine from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -27,6 +27,9 @@ class ZabbixServerAssignmentForm(NetBoxModelForm): device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False, selector=True, label=_('Device')) virtualdevicecontext = DynamicModelChoiceField(queryset=VirtualDeviceContext.objects.all(), required=False, selector=True, label=_('Virtual Device Context')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) fieldsets = ( FieldSet('zabbixserver', 'sync_enabled', name=_('Generic')), @@ -42,6 +45,9 @@ class ZabbixServerAssignmentForm(NetBoxModelForm): FieldSet('device', name=_('Device')), FieldSet('virtualdevicecontext', name=_('Virtual Device Context')), FieldSet('virtualmachine', name=_('Virtual Machine')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Device Assignment'), @@ -58,6 +64,9 @@ class Meta: 'device', 'virtualdevicecontext', 'virtualmachine', + 'site', + 'sitegroup', + 'region', 'zabbixconfigurationgroup', ) diff --git a/nbxsync/forms/zabbixtagassignment.py b/nbxsync/forms/zabbixtagassignment.py index d4dc78b5..d33b80b6 100644 --- a/nbxsync/forms/zabbixtagassignment.py +++ b/nbxsync/forms/zabbixtagassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD @@ -26,6 +26,9 @@ class ZabbixTagAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -43,6 +46,9 @@ class ZabbixTagAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -63,6 +69,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/forms/zabbixtemplateassignment.py b/nbxsync/forms/zabbixtemplateassignment.py index 78f5cd3a..036dc54a 100644 --- a/nbxsync/forms/zabbixtemplateassignment.py +++ b/nbxsync/forms/zabbixtemplateassignment.py @@ -6,7 +6,7 @@ from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, TagFilterField from utilities.forms.rendering import FieldSet, TabbedGroups -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.constants import ASSIGNMENT_TYPE_TO_FIELD, ASSIGNMENT_TYPE_TO_FIELD_NBOBJS @@ -26,6 +26,9 @@ class ZabbixTemplateAssignmentForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField(queryset=Manufacturer.objects.all(), required=False, selector=True, label=_('Manufacturer')) platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False, selector=True, label=_('Platform')) virtualmachine = DynamicModelChoiceField(queryset=VirtualMachine.objects.all(), required=False, selector=True, label=_('Virtual Machine')) + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, selector=True, label=_('Site')) + sitegroup = DynamicModelChoiceField(queryset=SiteGroup.objects.all(), required=False, selector=True, label=_('Site Group')) + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label=_('Region')) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False, selector=True, label=_('Cluster')) clustertype = DynamicModelChoiceField(queryset=ClusterType.objects.all(), required=False, selector=True, label=_('Cluster Type')) zabbixconfigurationgroup = DynamicModelChoiceField(queryset=ZabbixConfigurationGroup.objects.all(), required=False, selector=True, label=_('Zabbix Configuration Group')) @@ -43,6 +46,9 @@ class ZabbixTemplateAssignmentForm(NetBoxModelForm): FieldSet('virtualmachine', name=_('Virtual Machine')), FieldSet('cluster', name=_('Cluster')), FieldSet('clustertype', name=_('Cluster Type')), + FieldSet('site', name=_('Site')), + FieldSet('sitegroup', name=_('Site Group')), + FieldSet('region', name=_('Region')), FieldSet('zabbixconfigurationgroup', name=_('Zabbix Configuration Group')), ), name=_('Assignment'), @@ -63,6 +69,9 @@ class Meta: 'manufacturer', 'platform', 'zabbixconfigurationgroup', + 'site', + 'sitegroup', + 'region', ) @property diff --git a/nbxsync/forms/zabbixtemplaterule.py b/nbxsync/forms/zabbixtemplaterule.py new file mode 100644 index 00000000..c670511a --- /dev/null +++ b/nbxsync/forms/zabbixtemplaterule.py @@ -0,0 +1,52 @@ +from django import forms +from django.utils.translation import gettext as _ + +from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm +from utilities.forms.fields import DynamicModelChoiceField +from utilities.forms.rendering import FieldSet + +from nbxsync.models import ZabbixTemplate, ZabbixTemplateRule + +__all__ = ( + 'ZabbixTemplateRuleForm', + 'ZabbixTemplateRuleFilterForm', + 'ZabbixTemplateRuleBulkEditForm', +) + + +class ZabbixTemplateRuleForm(NetBoxModelForm): + zabbixtemplate = DynamicModelChoiceField(queryset=ZabbixTemplate.objects.all(), selector=True, label=_('Zabbix Template')) + + class Meta: + model = ZabbixTemplateRule + fields = ( + 'name', + 'description', + 'pattern', + 'zabbixtemplate', + 'enabled', + 'priority', + ) + + +class ZabbixTemplateRuleFilterForm(NetBoxModelFilterSetForm): + model = ZabbixTemplateRule + fieldsets = ( + FieldSet('q', 'filter_id'), + FieldSet('name', 'description', 'pattern', 'enabled', name=_('Zabbix Template Rule')), + ) + + enabled = forms.NullBooleanField(required=False, label=_('Enabled')) + + +class ZabbixTemplateRuleBulkEditForm(NetBoxModelBulkEditForm): + model = ZabbixTemplateRule + + description = forms.CharField(label=_('Description'), max_length=200, required=False) + pattern = forms.CharField(label=_('Pattern'), max_length=500, required=False) + zabbixtemplate = forms.ModelChoiceField(queryset=ZabbixTemplate.objects.all(), required=False, label=_('Zabbix Template')) + enabled = forms.NullBooleanField(required=False, label=_('Enabled')) + priority = forms.IntegerField(required=False, label=_('Priority')) + + fieldsets = (FieldSet('description', 'pattern', 'zabbixtemplate', 'enabled', 'priority', name=_('Zabbix Template Rule')),) + nullable_fields = ('description',) diff --git a/nbxsync/jobs/synchost.py b/nbxsync/jobs/synchost.py index 4e237176..5e8a13e8 100644 --- a/nbxsync/jobs/synchost.py +++ b/nbxsync/jobs/synchost.py @@ -1,13 +1,17 @@ +import copy +import logging + from django.contrib.contenttypes.models import ContentType from nbxsync.choices.zabbixstatus import ZabbixHostStatus -from nbxsync.models import ZabbixServerAssignment from nbxsync.settings import get_plugin_settings from nbxsync.utils import get_assigned_zabbixobjects 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 +logger = logging.getLogger(__name__) + __all__ = ('SyncHostJob',) @@ -15,10 +19,27 @@ class SyncHostJob: def __init__(self, **kwargs): self.instance = kwargs.get('instance') # This is the Device or VirtualMachine object - def run(self): - object_ct = ContentType.objects.get_for_model(self.instance) + def _prepare_assignment(self, assignment): + """ + If the assignment is inherited (not directly on this device/VM), + create a detached copy so that hostid and sync metadata cannot be + persisted back to the Site-level (or Platform-level) assignment row. + + Uses pk=None (Django idiom for "new object") so any accidental save() + would INSERT rather than UPDATE the original row. The _is_inherited_copy + flag is checked by SyncBase.sync() to skip save() entirely. + """ + instance_ct = ContentType.objects.get_for_model(self.instance) + is_direct = assignment.assigned_object_type_id == instance_ct.id and assignment.assigned_object_id == self.instance.pk + if not is_direct: + assignment = copy.copy(assignment) + assignment.pk = None + assignment._is_inherited_copy = True + return assignment - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=self.instance.pk) + def run(self): + all_objects = get_assigned_zabbixobjects(self.instance) + zabbixserver_assignments = all_objects.get('server_assignments', []) status = self.instance.status object_type = self.instance._meta.model_name # "device" or "virtualmachine" @@ -28,7 +49,11 @@ def run(self): for assignment in zabbixserver_assignments: if not assignment.sync_enabled or not assignment.zabbixserver.sync_enabled: - return + continue + + # Detach inherited assignments so hostid/sync-info is not written + # back to the source row (e.g. a Site-level assignment). + assignment = self._prepare_assignment(assignment) if zabbix_status == ZabbixHostStatus.DELETED: self.delete_host(assignment) @@ -42,10 +67,12 @@ def delete_host(self, assignment): def verify_hostinterfaces(self, assignment): all_objects = get_assigned_zabbixobjects(self.instance, zabbixserver=assignment.zabbixserver) + all_objects['_instance'] = self.instance 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, zabbixserver=assignment.zabbixserver) + all_objects['_instance'] = self.instance run_zabbix_operation(HostSync, assignment, 'check_default_hostinterface', extra_args={'all_objects': all_objects}) def sync_host(self, assignment): @@ -53,6 +80,7 @@ def sync_host(self, assignment): 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 + all_objects['_instance'] = self.instance # Create all hostgroups for hostgroup in all_objects['hostgroups']: @@ -77,7 +105,7 @@ def sync_host(self, assignment): # This can happen, in cases where the host exists, a new HostInterface is added (SNMP for example) and a new template (which requires SNMP) # In such cases, the Host Update will fail, due to the Interface not existing yet. # Fail silently, so we can create the interface - and we'll sync the template on the next run... - pass + logger.warning(f'Initial HostSync failed for {self.instance}: {e}') # Once the Host exists and we have a HostId, time to sync the interfaces # Sort by: @@ -86,7 +114,15 @@ def sync_host(self, assignment): # - id hostinterfaces_sorted = sorted(all_objects['hostinterfaces'], key=lambda hostinterface: (-int(hostinterface.interface_type == 1), hostinterface.type, hostinterface.id)) for hostinterface in hostinterfaces_sorted: - safe_sync(HostInterfaceSync, hostinterface, extra_args={'hostid': assignment.hostid}) + try: + safe_sync(HostInterfaceSync, hostinterface, extra_args={'hostid': assignment.hostid, '_instance': self.instance}) + except Exception as e: + # Continue syncing remaining interfaces even if one fails. + # A common case: device inherits both Agent and SNMP + # interfaces but the SNMP credentials are wrong — this + # should not prevent the Agent interface and templates + # from being synced. + logger.warning(f'HostInterfaceSync failed for {self.instance}: {e}') safe_sync(HostSync, assignment, extra_args={'all_objects': all_objects}) diff --git a/nbxsync/migrations/0013_zabbixtemplaterule.py b/nbxsync/migrations/0013_zabbixtemplaterule.py new file mode 100644 index 00000000..df069f05 --- /dev/null +++ b/nbxsync/migrations/0013_zabbixtemplaterule.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('nbxsync', '0012_zabbixserver_skip_version_check'), + ] + + operations = [ + migrations.CreateModel( + name='ZabbixTemplateRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('pattern', models.CharField(max_length=500)), + ('enabled', models.BooleanField(default=True)), + ('priority', models.IntegerField(default=100)), + ('zabbixtemplate', models.ForeignKey(on_delete=models.deletion.CASCADE, related_name='zabbixtemplaterules', to='nbxsync.zabbixtemplate')), + ], + options={ + 'verbose_name': 'Zabbix Template Rule', + 'verbose_name_plural': 'Zabbix Template Rules', + 'ordering': ('priority', 'name'), + }, + ), + ] diff --git a/nbxsync/models/__init__.py b/nbxsync/models/__init__.py index 9725e6cc..2793ea0b 100644 --- a/nbxsync/models/__init__.py +++ b/nbxsync/models/__init__.py @@ -28,3 +28,4 @@ from .zabbixproblem import * from .zabbixevent import * +from .zabbixtemplaterule import * diff --git a/nbxsync/models/sync_info.py b/nbxsync/models/sync_info.py index 6e620f86..3b09eb0b 100644 --- a/nbxsync/models/sync_info.py +++ b/nbxsync/models/sync_info.py @@ -20,6 +20,11 @@ def update_sync_info(self, success: bool, message: str = ''): success (bool): Whether the sync was successful. message (str): Optional message to describe the sync outcome. """ + # Skip persistence for inherited copies (pk=None) — these are transient + # copies of assignments from Site/Platform/etc. and must not write back. + if getattr(self, '_is_inherited_copy', False): + return + if success and message == '': message = 'Synchronization successful' diff --git a/nbxsync/models/zabbixhostinventory.py b/nbxsync/models/zabbixhostinventory.py index 91aeb639..6c35f882 100644 --- a/nbxsync/models/zabbixhostinventory.py +++ b/nbxsync/models/zabbixhostinventory.py @@ -9,7 +9,7 @@ from utilities.jinja2 import render_jinja2 from nbxsync.choices import ZabbixHostInventoryModeChoices -from nbxsync.constants import DEVICE_OR_VM_ASSIGNMENT_MODELS +from nbxsync.constants import DEVICE_OR_VM_ASSIGNMENT_MODELS, CONFIGGROUP_OBJECTS __all__ = ('ZabbixHostInventory',) @@ -87,7 +87,7 @@ class ZabbixHostInventory(NetBoxModel): url_c = models.CharField(max_length=2048, blank=True, verbose_name=_('URL C')) vendor = models.CharField(max_length=64, blank=True, verbose_name=_('Vendor')) - assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=DEVICE_OR_VM_ASSIGNMENT_MODELS, on_delete=models.CASCADE, related_name='+', blank=True, null=True) + assigned_object_type = models.ForeignKey(to=ContentType, limit_choices_to=(DEVICE_OR_VM_ASSIGNMENT_MODELS | CONFIGGROUP_OBJECTS), on_delete=models.CASCADE, related_name='+', blank=True, null=True) assigned_object_id = models.PositiveBigIntegerField(blank=True, null=True) assigned_object = GenericForeignKey(ct_field='assigned_object_type', fk_field='assigned_object_id') diff --git a/nbxsync/models/zabbixtemplaterule.py b/nbxsync/models/zabbixtemplaterule.py new file mode 100644 index 00000000..e7225ff7 --- /dev/null +++ b/nbxsync/models/zabbixtemplaterule.py @@ -0,0 +1,85 @@ +import logging +import re +import signal + +from django.core.exceptions import ValidationError +from django.db import models + +from netbox.models import NetBoxModel + +logger = logging.getLogger(__name__) + +__all__ = ('ZabbixTemplateRule',) + +_REGEX_TIMEOUT = 2 # seconds + + +def _timed_regex_search(pattern, text, timeout=_REGEX_TIMEOUT): + """Run re.search with a timeout via signal.alarm. + + Works in the main thread (Django views, RQ worker jobs). If called from + a sub-thread where signals are unavailable, falls back to an unbounded + re.search — acceptable because platform names are short (<100 chars) and + patterns are validated at model save time. + """ + + class _RegexTimeoutError(Exception): + pass + + def _alarm_handler(signum, frame): + raise _RegexTimeoutError() + + try: + old_handler = signal.signal(signal.SIGALRM, _alarm_handler) + except ValueError: + return re.search(pattern, text, re.IGNORECASE) + + old_alarm = signal.alarm(0) + try: + signal.alarm(timeout) + try: + return re.search(pattern, text, re.IGNORECASE) + except _RegexTimeoutError: + raise TimeoutError(f'Regex search timed out after {timeout}s') + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, old_handler) + if old_alarm: + signal.alarm(old_alarm) + + +class ZabbixTemplateRule(NetBoxModel): + name = models.CharField(max_length=100, blank=False) + description = models.CharField(max_length=200, blank=True) + pattern = models.CharField(max_length=500, blank=False, help_text='Regex pattern matched against Platform name') + zabbixtemplate = models.ForeignKey(to='nbxsync.ZabbixTemplate', on_delete=models.CASCADE, related_name='zabbixtemplaterules') + enabled = models.BooleanField(default=True) + priority = models.IntegerField(default=100, help_text='Lower value = higher priority') + + class Meta: + verbose_name = 'Zabbix Template Rule' + verbose_name_plural = 'Zabbix Template Rules' + ordering = ('priority', 'name') + + def clean(self): + super().clean() + try: + re.compile(self.pattern) + except re.error as e: + raise ValidationError({'pattern': f'Invalid regex: {e}'}) + + def matches(self, platform_name): + if not platform_name or not self.enabled: + return False + try: + match = _timed_regex_search(self.pattern, platform_name) + except TimeoutError: + logger.warning( + 'Regex timeout for rule "%s" pattern "%s" on "%s"', + self.name, self.pattern, platform_name[:50], + ) + return False + return bool(match) + + def __str__(self): + return f'{self.name} ({self.pattern})' diff --git a/nbxsync/navigation.py b/nbxsync/navigation.py index 108cbb2d..7db12f91 100644 --- a/nbxsync/navigation.py +++ b/nbxsync/navigation.py @@ -109,5 +109,17 @@ ), ), ), + PluginMenuItem( + link='plugins:nbxsync:zabbixtemplaterule_list', + link_text='Template Rules', + permissions=['nbxsync.view_zabbixtemplaterule'], + buttons=( + PluginMenuButton( + link='plugins:nbxsync:zabbixtemplaterule_add', + title='Add', + icon_class='mdi mdi-plus-thick', + ), + ), + ), ) menu = PluginMenu(label='Zabbix', groups=(('zabbix', items),), icon_class='mdi mdi-monitor-multiple') diff --git a/nbxsync/settings.py b/nbxsync/settings.py index ebcd0c2f..d1d351f4 100644 --- a/nbxsync/settings.py +++ b/nbxsync/settings.py @@ -57,6 +57,13 @@ class PluginSettingsModel(BaseModel): backgroundsync: BackgroundSync = Field(default_factory=BackgroundSync) inheritance_chain: List[Tuple[str, ...]] = Field( default_factory=lambda: [ + ('device', 'site'), + ('site',), + ('site', 'group'), + ('site', 'region'), + ('region',), + ('region', 'parent'), + ('cluster', 'site'), ('device',), ('role',), ( diff --git a/nbxsync/tables/__init__.py b/nbxsync/tables/__init__.py index e6092562..fe2f0739 100644 --- a/nbxsync/tables/__init__.py +++ b/nbxsync/tables/__init__.py @@ -23,3 +23,4 @@ # Zabbix Operational View from .zabbixproblem import * from .zabbixevent import * +from .zabbixtemplaterule import * diff --git a/nbxsync/tables/zabbixtemplaterule.py b/nbxsync/tables/zabbixtemplaterule.py new file mode 100644 index 00000000..ed48aef2 --- /dev/null +++ b/nbxsync/tables/zabbixtemplaterule.py @@ -0,0 +1,33 @@ +import django_tables2 as tables +from netbox.tables import NetBoxTable + +from nbxsync.models import ZabbixTemplateRule + +__all__ = ('ZabbixTemplateRuleTable',) + + +class ZabbixTemplateRuleTable(NetBoxTable): + name = tables.Column(linkify=True) + zabbixtemplate = tables.Column(linkify=True) + + class Meta(NetBoxTable.Meta): + model = ZabbixTemplateRule + fields = ( + 'pk', + 'name', + 'description', + 'pattern', + 'zabbixtemplate', + 'enabled', + 'priority', + 'created', + 'last_updated', + ) + default_columns = ( + 'pk', + 'name', + 'pattern', + 'zabbixtemplate', + 'enabled', + 'priority', + ) diff --git a/nbxsync/template_content.py b/nbxsync/template_content.py index 6dc66305..bcf57b81 100644 --- a/nbxsync/template_content.py +++ b/nbxsync/template_content.py @@ -1,9 +1,5 @@ -from django.contrib.contenttypes.models import ContentType - from netbox.plugins import PluginTemplateExtension - -from nbxsync.models import ZabbixHostInterface, ZabbixServerAssignment, ZabbixTemplateAssignment from nbxsync.choices import HostInterfaceRequirementChoices, ZabbixInterfaceTypeChoices from nbxsync.utils import get_assigned_zabbixobjects, get_maintenance_can_sync @@ -53,14 +49,17 @@ def buttons(self): if not obj: return '' - ct = ContentType.objects.get_for_model(obj) - has_server_assignment = ZabbixServerAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=obj.pk).exists() + assigned = get_assigned_zabbixobjects(obj) + has_server_assignment = bool(assigned.get('server_assignments')) - hostgroups = get_assigned_zabbixobjects(obj).get('hostgroups') or [] - has_hostgroup_assignment = bool(hostgroups) + has_hostgroup_assignment = bool(assigned.get('hostgroups')) - assigned_hostinterface_types = set(ZabbixHostInterface.objects.filter(assigned_object_type=ct, interface_type=ZabbixInterfaceTypeChoices.DEFAULT, assigned_object_id=obj.pk).values_list('type', flat=True).distinct()) - assigned_zabbixtemplates = list(ZabbixTemplateAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=obj.pk)) + hostinterfaces = assigned.get('hostinterfaces') or [] + assigned_hostinterface_types = set( + hi.type for hi in hostinterfaces + if int(getattr(hi, 'interface_type', 0)) == ZabbixInterfaceTypeChoices.DEFAULT + ) + assigned_zabbixtemplates = assigned.get('templates') or [] has_hostinterface_assignment = True diff --git a/nbxsync/templates/nbxsync/tabs/minimal.html b/nbxsync/templates/nbxsync/tabs/minimal.html index da94da4e..15a8c0a6 100644 --- a/nbxsync/templates/nbxsync/tabs/minimal.html +++ b/nbxsync/templates/nbxsync/tabs/minimal.html @@ -7,7 +7,60 @@ {% load render_table from django_tables2 %} {% load plugins %} {% block content %} - {% block extra_cards %}{% endblock %} + {% block extra_cards %} +
+
+
+

+ Zabbix Servers + {% if perms.nbxsync.add_zabbixserverassignment %} + + {% endif %} +

+ {% if zabbixserver_assignments_table %} + {% render_table zabbixserver_assignments_table %} + {% else %} +
No Zabbix Servers assigned
+ {% endif %} +
+
+
+
+

Miscellaneous

+
+ + + + + + + + + + +
Host Inventory + {% if hostinventory_assignment %} + Host Inventory of {{ hostinventory_assignment.assigned_object }} + {% else %} + - + {% endif %} +
Configuration Group + {% if configurationgroup_assignment %} + {{ configurationgroup_assignment.zabbixconfigurationgroup }} + {% else %} + - + {% endif %} +
+
+
+
+
+ {% endblock %}
diff --git a/nbxsync/tests/jobs/test_synchost.py b/nbxsync/tests/jobs/test_synchost.py index 57301928..38e9e154 100644 --- a/nbxsync/tests/jobs/test_synchost.py +++ b/nbxsync/tests/jobs/test_synchost.py @@ -4,7 +4,7 @@ from django.test import TestCase from ipam.models import IPAddress -from dcim.models import Device +from dcim.models import Device, Site from utilities.testing import create_test_device from nbxsync.choices import ZabbixProxyTypeChoices, ZabbixTLSChoices @@ -168,6 +168,12 @@ def side_effect(sync_class, *args, **kwargs): mock_gao.return_value = { 'hostgroups': [], 'hostinterfaces': [self.hostinterface], + 'server_assignments': [self.zabbixserverassignment], + 'templates': [], + 'macros': [], + 'tags': [], + 'hostinventory': None, + 'configurationgroup': None, } job.run() @@ -208,3 +214,73 @@ def test_run_skips_sync_when_both_assignment_and_zabbixserver_sync_disabled(self job.run() mock_safe_sync.assert_not_called() + + def test_prepare_assignment_returns_original_for_direct(self): + job = SyncHostJob(instance=self.device) + + prepared = job._prepare_assignment(self.zabbixserverassignment) + + self.assertEqual(prepared.pk, self.zabbixserverassignment.pk) + self.assertFalse(getattr(prepared, '_is_inherited_copy', False)) + + def test_prepare_assignment_copies_inherited(self): + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + + site_assignment = ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + zabbixproxy=self.proxy, + ) + + job = SyncHostJob(instance=self.device) + prepared = job._prepare_assignment(site_assignment) + + self.assertIsNone(prepared.pk) + self.assertTrue(getattr(prepared, '_is_inherited_copy', False)) + # Original assignment is untouched + self.assertIsNotNone(site_assignment.pk) + + @patch('nbxsync.jobs.synchost.safe_sync') + @patch.object(SyncHostJob, 'verify_hostinterfaces') + @patch.object(SyncHostJob, 'check_default_hostinterface') + def test_run_syncs_inherited_assignment_from_site(self, mock_check, mock_verify, mock_safe_sync): + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + + # Remove the direct assignment so only inherited remains + self.zabbixserverassignment.delete() + + ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + zabbixproxy=self.proxy, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + # safe_sync should have been called (host groups, proxy, host, interfaces) + self.assertTrue(mock_safe_sync.called) + + @patch('nbxsync.jobs.synchost.safe_sync') + def test_run_continues_when_assignment_sync_disabled(self, mock_safe_sync): + # Create two assignments: one disabled (site-level), one enabled (direct) + site = self.device.site + site_ct = ContentType.objects.get_for_model(Site) + + ZabbixServerAssignment.objects.create( + zabbixserver=self.zabbixserver, + assigned_object_type=site_ct, + assigned_object_id=site.pk, + zabbixproxy=self.proxy, + sync_enabled=False, + ) + + job = SyncHostJob(instance=self.device) + job.run() + + # Direct assignment is enabled, so safe_sync should still be called + self.assertTrue(mock_safe_sync.called) diff --git a/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py b/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py index 6ba98b4c..a2d83e2f 100644 --- a/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py +++ b/nbxsync/tests/utils/test_get_assigned_zabbixobjects.py @@ -1,10 +1,11 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import Device, DeviceType, Manufacturer +from dcim.models import Device, DeviceType, Manufacturer, Site, SiteGroup from utilities.testing import create_test_device -from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixMacro, ZabbixMacroAssignment, ZabbixServer, ZabbixTag, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment +from nbxsync.choices import ZabbixProxyTypeChoices +from nbxsync.models import ZabbixHostgroup, ZabbixHostgroupAssignment, ZabbixHostInventory, ZabbixMacro, ZabbixMacroAssignment, ZabbixProxy, ZabbixServer, ZabbixServerAssignment, ZabbixTag, ZabbixTagAssignment, ZabbixTemplate, ZabbixTemplateAssignment from nbxsync.utils.inheritance import get_assigned_zabbixobjects @@ -16,6 +17,8 @@ def setUp(self): self.device_ct = ContentType.objects.get_for_model(Device) self.manufacturer_ct = ContentType.objects.get_for_model(Manufacturer) + self.site_ct = ContentType.objects.get_for_model(Site) + self.sitegroup_ct = ContentType.objects.get_for_model(SiteGroup) self.server = ZabbixServer.objects.create(name='Zabbix1', url='http://localhost', token='abc123', validate_certs=True) @@ -24,6 +27,19 @@ def setUp(self): self.tag = ZabbixTag.objects.create(tag='env', value='prod') self.group = ZabbixHostgroup.objects.create(name='ProdGroup', groupid=201, value='prod', zabbixserver=self.server) + self.site = self.device.site + self.sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + self.site.group = self.sitegroup + self.site.save() + + self.proxy = ZabbixProxy.objects.create( + name='Proxy1', + zabbixserver=self.server, + operating_mode=ZabbixProxyTypeChoices.ACTIVE, + local_address='10.0.0.1', + local_port=10051, + ) + def test_inherited_assignments(self): self.template_assignment = ZabbixTemplateAssignment.objects.create(zabbixtemplate=self.template, assigned_object_type=self.manufacturer_ct, assigned_object_id=self.manufacturer.pk) self.macro_assignment = ZabbixMacroAssignment.objects.create(zabbixmacro=self.macro, assigned_object_type=self.manufacturer_ct, assigned_object_id=self.manufacturer.pk, value='mval') @@ -49,3 +65,88 @@ def test_direct_assignments(self): self.assertEqual(len(result['macros']), 1) self.assertEqual(len(result['tags']), 1) self.assertEqual(len(result['hostgroups']), 1) + + def test_inherited_server_assignment_from_site(self): + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.site_ct, + assigned_object_id=self.site.pk, + zabbixproxy=self.proxy, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(len(result['server_assignments']), 1) + sa = result['server_assignments'][0] + self.assertEqual(sa.zabbixserver, self.server) + self.assertEqual(sa.zabbixproxy, self.proxy) + + def test_inherited_server_assignment_from_sitegroup(self): + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.sitegroup_ct, + assigned_object_id=self.sitegroup.pk, + zabbixproxy=self.proxy, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(len(result['server_assignments']), 1) + sa = result['server_assignments'][0] + self.assertEqual(sa.zabbixserver, self.server) + self.assertEqual(sa.zabbixproxy, self.proxy) + + def test_direct_server_assignment_takes_priority_over_inherited(self): + proxy_direct = ZabbixProxy.objects.create( + name='Proxy2', + zabbixserver=self.server, + operating_mode=ZabbixProxyTypeChoices.ACTIVE, + local_address='10.0.0.2', + local_port=10051, + ) + + # Direct assignment on the device + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.device_ct, + assigned_object_id=self.device.pk, + zabbixproxy=proxy_direct, + ) + # Inherited assignment on the site + ZabbixServerAssignment.objects.create( + zabbixserver=self.server, + assigned_object_type=self.site_ct, + assigned_object_id=self.site.pk, + zabbixproxy=self.proxy, + ) + + result = get_assigned_zabbixobjects(self.device) + + # Both should be present (direct + inherited) + self.assertEqual(len(result['server_assignments']), 1) + sa = result['server_assignments'][0] + # Direct takes priority + self.assertEqual(sa.zabbixproxy, proxy_direct) + + def test_inherited_hostinventory_from_manufacturer(self): + ZabbixHostInventory.objects.create( + assigned_object_type=self.manufacturer_ct, + assigned_object_id=self.manufacturer.pk, + inventory_mode=1, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertIsNotNone(result['hostinventory']) + self.assertEqual(result['hostinventory'].inventory_mode, 1) + + def test_no_assignments_returns_empty(self): + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + self.assertEqual(result['macros'], []) + self.assertEqual(result['tags'], []) + self.assertEqual(result['hostgroups'], []) + self.assertEqual(result['server_assignments'], []) + self.assertIsNone(result['hostinventory']) + self.assertIsNone(result['configurationgroup']) diff --git a/nbxsync/tests/utils/test_hostsync.py b/nbxsync/tests/utils/test_hostsync.py index 5f2b193e..a7bc12af 100644 --- a/nbxsync/tests/utils/test_hostsync.py +++ b/nbxsync/tests/utils/test_hostsync.py @@ -467,7 +467,7 @@ class DummyHostInventory: def __init__(self): self.inventory_mode = 1 - def render_all_fields(self): + def render_all_fields(self, object=None): return { 'serialnumber': ('ABC123', True), 'location': ('', True), # Empty, should be skipped diff --git a/nbxsync/tests/utils/test_zabbixtemplaterule.py b/nbxsync/tests/utils/test_zabbixtemplaterule.py new file mode 100644 index 00000000..85ceeaef --- /dev/null +++ b/nbxsync/tests/utils/test_zabbixtemplaterule.py @@ -0,0 +1,167 @@ +from unittest.mock import patch + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from dcim.models import Platform +from utilities.testing import create_test_device + +from nbxsync.models import ZabbixServer, ZabbixTemplate, ZabbixTemplateAssignment, ZabbixTemplateRule +from nbxsync.utils.inheritance import get_assigned_zabbixobjects + + +class ZabbixTemplateRuleTestCase(TestCase): + def setUp(self): + self.device = create_test_device(name='TestDev') + self.server = ZabbixServer.objects.create(name='Zabbix1', url='http://zabbix.local', token='abc123', validate_certs=True) + self.template_windows = ZabbixTemplate.objects.create(name='Windows by Zabbix agent', zabbixserver=self.server, templateid=10081) + self.template_linux = ZabbixTemplate.objects.create(name='Linux by Zabbix agent', zabbixserver=self.server, templateid=10001) + self.platform_ct = ContentType.objects.get_for_model(Platform) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_regex_rule_matches_platform_name(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Server 2022 (Build 20348.5400)', slug='win-2022-5400') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create( + name='Windows', + pattern='Windows', + zabbixtemplate=self.template_windows, + ) + + result = get_assigned_zabbixobjects(self.device) + + templates = result['templates'] + self.assertEqual(len(templates), 1) + self.assertEqual(templates[0].zabbixtemplate, self.template_windows) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_regex_rule_does_not_match_wrong_platform(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Ubuntu 24.04 LTS', slug='ubuntu-2404') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create( + name='Windows', + pattern='Windows', + zabbixtemplate=self.template_windows, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_multiple_rules_match_correctly(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + win_platform = Platform.objects.create(name='Windows Server 2019', slug='win2019') + linux_platform = Platform.objects.create(name='Ubuntu 22.04 LTS', slug='ubuntu2204') + + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows) + ZabbixTemplateRule.objects.create(name='Linux', pattern='Ubuntu|Debian|Red Hat|CentOS', zabbixtemplate=self.template_linux) + + # Windows device + self.device.platform = win_platform + self.device.save() + result = get_assigned_zabbixobjects(self.device) + self.assertEqual(len(result['templates']), 1) + self.assertEqual(result['templates'][0].zabbixtemplate, self.template_windows) + + # Linux device + device2 = create_test_device(name='TestDev2') + device2.platform = linux_platform + device2.save() + result2 = get_assigned_zabbixobjects(device2) + self.assertEqual(len(result2['templates']), 1) + self.assertEqual(result2['templates'][0].zabbixtemplate, self.template_linux) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_direct_assignment_overrides_regex_rule(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Server 2022', slug='win2022') + self.device.platform = platform + self.device.save() + + # Direct assignment on the device + ZabbixTemplateAssignment.objects.create( + zabbixtemplate=self.template_linux, + assigned_object_type=ContentType.objects.get_for_model(type(self.device)), + assigned_object_id=self.device.pk, + ) + + # Regex rule that would match Windows + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows) + + result = get_assigned_zabbixobjects(self.device) + + # Direct assignment (template_linux) + regex rule (template_windows) + # Both should appear because they are different templates + # but direct assignment prevents the SAME template from being added by regex + templates = result['templates'] + self.assertEqual(len(templates), 2) + template_ids = {t.zabbixtemplate_id for t in templates} + self.assertEqual(template_ids, {self.template_linux.id, self.template_windows.id}) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_disabled_rule_ignored(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Server 2022', slug='win2022') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create( + name='Windows', + pattern='Windows', + zabbixtemplate=self.template_windows, + enabled=False, + ) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_case_insensitive_matching(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='UBUNTU 24.04 LTS', slug='ubuntu-2404') + self.device.platform = platform + self.device.save() + + ZabbixTemplateRule.objects.create(name='Linux', pattern='ubuntu', zabbixtemplate=self.template_linux) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(len(result['templates']), 1) + self.assertEqual(result['templates'][0].zabbixtemplate, self.template_linux) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_device_without_platform_no_rules(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows) + + result = get_assigned_zabbixobjects(self.device) + + self.assertEqual(result['templates'], []) + + @patch('nbxsync.utils.inheritance.get_plugin_settings') + def test_rule_priority_ordering(self, mock_settings): + mock_settings.return_value.inheritance_chain = [] + platform = Platform.objects.create(name='Windows Ubuntu Hybrid', slug='hybrid') + self.device.platform = platform + self.device.save() + + # Both rules match, but Windows has lower priority value (higher priority) + ZabbixTemplateRule.objects.create(name='Linux', pattern='Ubuntu', zabbixtemplate=self.template_linux, priority=200) + ZabbixTemplateRule.objects.create(name='Windows', pattern='Windows', zabbixtemplate=self.template_windows, priority=100) + + result = get_assigned_zabbixobjects(self.device) + + # Both should match (different templates) + templates = result['templates'] + self.assertEqual(len(templates), 2) + template_ids = {t.zabbixtemplate_id for t in templates} + self.assertEqual(template_ids, {self.template_windows.id, self.template_linux.id}) diff --git a/nbxsync/urls.py b/nbxsync/urls.py index c8674e6b..6000c268 100644 --- a/nbxsync/urls.py +++ b/nbxsync/urls.py @@ -197,6 +197,15 @@ path('zabbixconfigurationgroupassignment//edit/', ZabbixConfigurationGroupAssignmentEditView.as_view(), name='zabbixconfigurationgroupassignment_edit'), path('zabbixconfigurationgroupassignment//delete/', ZabbixConfigurationGroupAssignmentDeleteView.as_view(), name='zabbixconfigurationgroupassignment_delete'), path('zabbixconfigurationgroupassignment//changelog/', ObjectChangeLogView.as_view(), name='zabbixconfigurationgroupassignment_changelog', kwargs={'model': ZabbixConfigurationGroupAssignment}), + # Zabbix Template Rule + path('zabbixtemplaterule/', ZabbixTemplateRuleListView.as_view(), name='zabbixtemplaterule_list'), + path('zabbixtemplaterule/add/', ZabbixTemplateRuleEditView.as_view(), name='zabbixtemplaterule_add'), + path('zabbixtemplaterule/edit/', ZabbixTemplateRuleBulkEditView.as_view(), name='zabbixtemplaterule_bulk_edit'), + path('zabbixtemplaterule/delete/', ZabbixTemplateRuleBulkDeleteView.as_view(), name='zabbixtemplaterule_bulk_delete'), + path('zabbixtemplaterule//', ZabbixTemplateRuleView.as_view(), name='zabbixtemplaterule'), + path('zabbixtemplaterule//edit/', ZabbixTemplateRuleEditView.as_view(), name='zabbixtemplaterule_edit'), + path('zabbixtemplaterule//delete/', ZabbixTemplateRuleDeleteView.as_view(), name='zabbixtemplaterule_delete'), + path('zabbixtemplaterule//changelog/', ObjectChangeLogView.as_view(), name='zabbixtemplaterule_changelog', kwargs={'model': ZabbixTemplateRule}), # Sync Device/VM Object path('zabbixhost///sync', TriggerHostSyncJobView.as_view(), name='zabbixhost_sync'), path('zabbixhost///sync-info/', ZabbixSyncInfoModalView.as_view(), name='zabbixhost_info'), diff --git a/nbxsync/utils/inheritance.py b/nbxsync/utils/inheritance.py index 133caa16..c57d7ebf 100644 --- a/nbxsync/utils/inheritance.py +++ b/nbxsync/utils/inheritance.py @@ -1,13 +1,14 @@ +import copy as _copy from collections import OrderedDict -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 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, ZabbixHostgroupAssignment, ZabbixHostInterface, ZabbixHostInventory, ZabbixMacroAssignment, ZabbixServerAssignment, ZabbixTagAssignment, ZabbixTemplateAssignment, ZabbixTemplateRule from nbxsync.settings import get_plugin_settings -from nbxsync.tables import ZabbixHostgroupAssignmentObjectViewTable, ZabbixMacroAssignmentObjectViewTable, ZabbixTagAssignmentObjectViewTable, ZabbixTemplateAssignmentObjectViewTable +from nbxsync.tables import ZabbixHostgroupAssignmentObjectViewTable, ZabbixMacroAssignmentObjectViewTable, ZabbixServerAssignmentObjectViewTable, ZabbixTagAssignmentObjectViewTable, ZabbixTemplateAssignmentObjectViewTable def get_zabbixassignments_for_request(instance, request): @@ -28,10 +29,13 @@ def table_or_none(data, table_cls, attach_instance=False): return None return { + 'zabbixserver_assignments_table': table_or_none(assignments.get('server_assignments'), ZabbixServerAssignmentObjectViewTable), 'zabbix_template_table': table_or_none(assignments['templates'], ZabbixTemplateAssignmentObjectViewTable), 'zabbix_macro_table': table_or_none(assignments['macros'], ZabbixMacroAssignmentObjectViewTable, attach_instance=True), 'zabbix_tag_table': table_or_none(assignments['tags'], ZabbixTagAssignmentObjectViewTable), 'zabbix_hostgroup_table': table_or_none(assignments['hostgroups'], ZabbixHostgroupAssignmentObjectViewTable), + 'hostinventory_assignment': assignments.get('hostinventory'), + 'configurationgroup_assignment': assignments.get('configurationgroup'), 'object': instance, 'content_type': content_type, } @@ -65,31 +69,103 @@ def get_assigned_zabbixobjects(instance, zabbixserver=None): 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) + direct_hostinterfaces = list(hostinterfaces_qs) + + direct_server_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).select_related('zabbixproxy', 'zabbixproxygroup') + if zabbixserver: + direct_server_assignments = direct_server_assignments.filter(zabbixserver=zabbixserver) + direct_server_assignments = list(direct_server_assignments) 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() + direct_configurationgroup = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=content_type, assigned_object_id=instance.id).first() inherited = resolve_inherited_zabbix_assignments(instance, zabbixserver) + if not hostinventory: + hostinventory = inherited.get('hostinventory') + + configurationgroup = direct_configurationgroup or next(iter(inherited.get('configurationgroups', {}).values()), None) + 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 + # Merge direct + inherited (direct takes priority) + # ZabbixHostInterfaces assigned to SiteGroup/Role/Site are resolved + # naturally by the inheritance chain. If the interface lacks an IP, + # HostInterfaceSync.get_create_params() falls back to the device's + # primary IP automatically. + hostinterfaces = merge(direct_hostinterfaces, inherited.get('hostinterfaces', {}), 'id') + # Expand ConfigGroup-defined interfaces for this specific instance. + # When a ConfigGroup is assigned at Site/Platform level, the signal-based + # propagation cannot clone per-device interfaces (Site has no primary_ip). + # Resolve them here so HostSync and HostInterfaceSync get device-specific interfaces. + if configurationgroup: + cg_ct = ContentType.objects.get_for_model(configurationgroup.zabbixconfigurationgroup) + cg_interfaces = ZabbixHostInterface.objects.filter( + assigned_object_type=cg_ct, + assigned_object_id=configurationgroup.zabbixconfigurationgroup_id, + ) + existing_types = {hi.type for hi in hostinterfaces} + primary_ip = getattr(instance, 'primary_ip4', None) or getattr(instance, 'primary_ip6', None) + for cg_iface in cg_interfaces: + if cg_iface.type not in existing_types: + # Clone the interface with the device's primary IP + child = _copy.copy(cg_iface) + child.pk = None + child._is_inherited_copy = True + child.assigned_object_type = content_type + child.assigned_object_id = instance.id + child.ip = primary_ip if primary_ip else None + hostinterfaces.append(child) + + # Resolve regex-based template rules (matched against platform name) + # These are applied after direct + inherited, so explicit assignments always win + merged_templates = merge(direct_templates, inherited['templates'], 'zabbixtemplate_id') + resolved_template_ids = {getattr(obj, 'zabbixtemplate_id') for obj in merged_templates} + merged_hostgroups = merge(direct_hostgroups, inherited['hostgroups'], 'zabbixhostgroup_id') + merged_tags = merge(direct_tags, inherited['tags'], 'id') + platform = getattr(instance, 'platform', None) + if platform: + rules_qs = ZabbixTemplateRule.objects.filter(enabled=True).select_related('zabbixtemplate') + if zabbixserver: + rules_qs = rules_qs.filter(zabbixtemplate__zabbixserver=zabbixserver) + for rule in rules_qs.order_by('priority', 'name'): + if not rule.matches(platform.name): + continue + inherited_from = f'Regex: {rule.name}' + if rule.zabbixtemplate_id and rule.zabbixtemplate_id not in resolved_template_ids: + # Create an unsaved ZabbixTemplateAssignment wrapper so downstream + # code (table rendering, sync) sees the same interface as real assignments. + wrapper = ZabbixTemplateAssignment( + zabbixtemplate=rule.zabbixtemplate, + assigned_object_type=content_type, + assigned_object_id=instance.id, + ) + wrapper.pk = None + wrapper._is_inherited_copy = True + wrapper._inherited_from = inherited_from + merged_templates.append(wrapper) + resolved_template_ids.add(rule.zabbixtemplate_id) + return { - 'templates': merge(direct_templates, inherited['templates'], 'zabbixtemplate_id'), + 'templates': merged_templates, 'macros': merge(direct_macros, inherited['macros'], 'zabbixmacro_id'), - 'tags': merge(direct_tags, inherited['tags'], 'id'), - 'hostgroups': merge(direct_hostgroups, inherited['hostgroups'], 'zabbixhostgroup_id'), + 'tags': merged_tags, + 'hostgroups': merged_hostgroups, 'hostinterfaces': hostinterfaces, 'hostinventory': hostinventory, 'configurationgroup': configurationgroup, + 'server_assignments': merge(direct_server_assignments, inherited.get('server_assignments', {}), 'zabbixserver_id'), } def resolve_inherited_zabbix_assignments(assigned_object, zabbixserver=None): resolved_templates = OrderedDict() + resolved_server_assignments = OrderedDict() + resolved_hostinterfaces = OrderedDict() + resolved_hostinventory = None resolved_macros = OrderedDict() resolved_tags = OrderedDict() resolved_hostgroups = OrderedDict() @@ -102,6 +178,7 @@ def resolve_inherited_zabbix_assignments(assigned_object, zabbixserver=None): def resolve_path(obj, path): cur = obj + seen = set() for attr in path: cur = getattr(cur, attr, None) if cur is None: @@ -112,6 +189,9 @@ def resolve_path(obj, path): # If it's something that still isn't a model instance after collapsing, bail if cur is None: return None + if cur in seen: + return None # cycle detected + seen.add(cur) return cur pluginsettings = get_plugin_settings() @@ -134,6 +214,13 @@ def resolve_path(obj, path): templates = templates.filter(zabbixtemplate__zabbixserver=zabbixserver) hostgroups = hostgroups.filter(zabbixhostgroup__zabbixserver=zabbixserver) + server_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).select_related('zabbixproxy', 'zabbixproxygroup') + hostinterfaces = ZabbixHostInterface.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk) + + if zabbixserver: + server_assignments = server_assignments.filter(zabbixserver=zabbixserver) + hostinterfaces = hostinterfaces.filter(zabbixserver=zabbixserver) + hostinventory = ZabbixHostInventory.objects.filter(assigned_object_type=ct, assigned_object_id=related_obj.pk).first() # 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: @@ -141,6 +228,19 @@ def resolve_path(obj, path): template._inherited_from = PATH_LABELS.get(path, '.'.join(path)) resolved_templates[template.zabbixtemplate_id] = template seen_template_ids.add(template.zabbixtemplate_id) + for sa in server_assignments: + if sa.zabbixserver_id not in resolved_server_assignments: + sa._inherited_from = PATH_LABELS.get(path, '.'.join(path)) + resolved_server_assignments[sa.zabbixserver_id] = sa + + if hostinventory and not resolved_hostinventory: + hostinventory._inherited_from = PATH_LABELS.get(path, '.'.join(path)) + resolved_hostinventory = hostinventory + + for hi in hostinterfaces: + if hi.id not in resolved_hostinterfaces: + hi._inherited_from = PATH_LABELS.get(path, '.'.join(path)) + resolved_hostinterfaces[hi.id] = hi for macro in macros: if macro.zabbixmacro_id not in seen_macro_ids: @@ -167,6 +267,9 @@ def resolve_path(obj, path): seen_configurationgroup_ids.add(configurationgroup.zabbixconfigurationgroup_id) return { + 'server_assignments': resolved_server_assignments, + 'hostinterfaces': resolved_hostinterfaces, + 'hostinventory': resolved_hostinventory, 'templates': resolved_templates, 'macros': resolved_macros, 'tags': resolved_tags, diff --git a/nbxsync/utils/sync/hostinterfacesync.py b/nbxsync/utils/sync/hostinterfacesync.py index 93a8cf86..7aee173f 100644 --- a/nbxsync/utils/sync/hostinterfacesync.py +++ b/nbxsync/utils/sync/hostinterfacesync.py @@ -14,6 +14,12 @@ def api_object(self): def get_name_value(self): return self.obj.assigned_object.name + def find_by_name(self): + hostid = self.context.get('hostid', None) + if not hostid: + return [] + return self.api_object().get(hostids=[hostid], filter={'type': str(self.obj.type)}) + def get_create_params(self): hostid = self.context.get('hostid', None) zbxserverassignment = None @@ -31,6 +37,13 @@ def get_create_params(self): ipaddr = '' if self.obj.ip_id: ipaddr = IPAddress.objects.get(id=self.obj.ip_id).address.ip + elif self.context.get('_instance'): + # If the interface is inherited (e.g. from SiteGroup or Role) + # and has no IP assigned, fall back to the device's primary IP + instance = self.context.get('_instance') + primary_ip = getattr(instance, 'primary_ip4', None) or getattr(instance, 'primary_ip6', None) + if primary_ip: + ipaddr = primary_ip.address.ip dns_name, _ = self.obj.render_dns() result = { diff --git a/nbxsync/utils/sync/hostsync.py b/nbxsync/utils/sync/hostsync.py index b9efbdfe..a1c777f0 100644 --- a/nbxsync/utils/sync/hostsync.py +++ b/nbxsync/utils/sync/hostsync.py @@ -1,3 +1,4 @@ +import logging import re from datetime import datetime, timedelta @@ -12,6 +13,8 @@ from nbxsync.models import ZabbixHostInterface, ZabbixMaintenance, ZabbixMaintenancePeriod, ZabbixMaintenanceObjectAssignment from nbxsync.utils.sync.hostinterfacesync import HostInterfaceSync +logger = logging.getLogger(__name__) + class HostSync(ZabbixSyncBase): id_field = 'hostid' @@ -20,27 +23,41 @@ class HostSync(ZabbixSyncBase): def api_object(self): return self.api.host + def _get_sync_target(self): + """Return the Device/VM being synced, falling back to the assignment's assigned_object. + + When a ZabbixServerAssignment is inherited from a Site, Platform, etc., + ``self.obj.assigned_object`` is that higher-level object, not the Device. + The sync engine passes the actual instance via ``all_objects['_instance']`` + so that status, description, serial, and other device-level attributes + resolve correctly. + """ + return self.context.get('all_objects', {}).get('_instance') or self.obj.assigned_object + 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 + sync_target = self._get_sync_target() + if hasattr(sync_target, 'name'): + return sync_target.name - return str(self.obj.assigned_object) + return str(sync_target) def get_name_value(self): + sync_target = self._get_sync_target() 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_name and hasattr(sync_target, 'custom_field_data'): + cf_value = sync_target.custom_field_data.get(cf_name) if cf_value: return str(cf_value) return base_name def get_display_name(self): + sync_target = self._get_sync_target() 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_name and hasattr(sync_target, 'custom_field_data'): + cf_value = sync_target.custom_field_data.get(cf_name) if cf_value: return str(cf_value) return base_name @@ -49,8 +66,9 @@ 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" + sync_target = self._get_sync_target() + status = sync_target.status + object_type = sync_target._meta.model_name # "device" or "virtualmachine" status_mapping = getattr(self.pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) @@ -65,7 +83,7 @@ def get_create_params(self): 'name': self.get_display_name(), 'groups': self.get_groups(), 'status': host_status, - 'description': self.obj.assigned_object.description or '', + 'description': sync_target.description or '', **self.get_proxy_or_proxygroup(), **self.get_hostinterface_attributes(), **self.get_tag_attributes(), @@ -117,7 +135,7 @@ def get_proxy_or_proxygroup(self): def get_defined_macros(self): result = [] for macro in self.context.get('all_objects', {}).get('macros'): - rendered_value, _ = macro.render(object=self.obj.assigned_object) + rendered_value, _ = macro.render(object=self._get_sync_target()) result.append( { 'macro': str(macro), @@ -308,8 +326,9 @@ def get_template_attributes(self): return {'templates': result} def get_tag_attributes(self): - status = self.obj.assigned_object.status - object_type = self.obj.assigned_object._meta.model_name # "device" or "virtualmachine" + sync_target = self._get_sync_target() + status = sync_target.status + object_type = sync_target._meta.model_name # "device" or "virtualmachine" status_mapping = getattr(self.pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) @@ -318,12 +337,25 @@ def get_tag_attributes(self): value, _ = assigned_tag.render() result.append({'tag': assigned_tag.zabbixtag.tag, 'value': value}) + # Deduplicate tags by (tag, value). The same tag value can be + # resolved from multiple sources in the inheritance chain (e.g. + # environment=Production inherited from both a specific role + # and the parent Server role), which Zabbix rejects. + seen = set() + deduped = [] + for tag in result: + key = (tag['tag'], tag['value']) + if key not in seen: + seen.add(key) + deduped.append(tag) + result = deduped + if zabbix_status == ZabbixHostStatus.ENABLED_NO_ALERTING: result.append({'tag': self.pluginsettings.no_alerting_tag, 'value': str(self.pluginsettings.no_alerting_tag_value)}) if self.pluginsettings.attach_objtag: - result.append({'tag': self.pluginsettings.objtag_type, 'value': str(type(self.obj.assigned_object).__name__).lower()}) - result.append({'tag': self.pluginsettings.objtag_id, 'value': str(self.obj.assigned_object.id)}) + result.append({'tag': self.pluginsettings.objtag_type, 'value': str(type(sync_target).__name__).lower()}) + result.append({'tag': self.pluginsettings.objtag_id, 'value': str(sync_target.id)}) return {'tags': result} @@ -358,13 +390,15 @@ def get_groups(self): def get_hostinventory(self): hostinventory = self.context.get('all_objects', {}).get('hostinventory', None) + sync_target = self._get_sync_target() inventory = {} inventory_mode = 0 if hostinventory: inventory_mode = hostinventory.inventory_mode or 0 - for field_name, (rendered_value, success) in hostinventory.render_all_fields().items(): + # Override the context object with the actual device/VM for Jinja2 rendering + for field_name, (rendered_value, success) in hostinventory.render_all_fields(object=sync_target).items(): if success and rendered_value: inventory[field_name] = rendered_value @@ -375,13 +409,14 @@ def get_hostinventory(self): return result def verify_maintenancewindow(self): - status = self.obj.assigned_object.status - object_type = self.obj.assigned_object._meta.model_name # "device" or "virtualmachine" + sync_target = self._get_sync_target() + status = sync_target.status + object_type = sync_target._meta.model_name # "device" or "virtualmachine" status_mapping = getattr(self.pluginsettings.statusmapping, object_type, {}) zabbix_status = status_mapping.get(status) - object_ct = ContentType.objects.get_for_model(self.obj.assigned_object) - mw_assignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=self.obj.assigned_object.id) + object_ct = ContentType.objects.get_for_model(sync_target) + mw_assignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=sync_target.id) if zabbix_status != ZabbixHostStatus.ENABLED_IN_MAINTENANCE: for assignment in mw_assignments: @@ -401,11 +436,11 @@ def verify_maintenancewindow(self): now = datetime.now() end_date = now + timedelta(seconds=int(self.pluginsettings.maintenance_window_duration)) # Create the Maintenance object - maintenance = ZabbixMaintenance(name=f'[AUTOMATIC] {str(self.obj.assigned_object)}', description='Automatically created maintenance object due to the object status', automatic=True, active_since=now, active_till=end_date, zabbixserver=self.obj.zabbixserver) + maintenance = ZabbixMaintenance(name=f'[AUTOMATIC] {str(sync_target)}', description='Automatically created maintenance object due to the object status', automatic=True, active_since=now, active_till=end_date, zabbixserver=self.obj.zabbixserver) maintenance.save() # Assign this host to the Maintenance object - ZabbixMaintenanceObjectAssignment(zabbixmaintenance=maintenance, assigned_object_type=object_ct, assigned_object_id=self.obj.assigned_object.id).save() + ZabbixMaintenanceObjectAssignment(zabbixmaintenance=maintenance, assigned_object_type=object_ct, assigned_object_id=sync_target.id).save() # And create the maintenance period seconds_of_day = now.hour * 3600 + now.minute * 60 + now.second ZabbixMaintenancePeriod(zabbixmaintenance=maintenance, start_date=now, start_time=seconds_of_day, period=int(self.pluginsettings.maintenance_window_duration)).save() @@ -563,12 +598,25 @@ def verify_hostinterfaces(self): # Extract the currently expected interfaces expected_hostinterfaces = self.context.get('all_objects', {}).get('hostinterfaces', []) - expected_ids = {int(expected_hostinterface.interfaceid) for expected_hostinterface in expected_hostinterfaces} + expected_ids = {int(expected_hostinterface.interfaceid) for expected_hostinterface in expected_hostinterfaces if expected_hostinterface.interfaceid} # Get currently assigned hostinterface from Zabbix current_hostinterfaces = self.api.hostinterface.get(output=['extend'], hostids=self.obj.hostid) current_ids = {int(current_hostinterface['interfaceid']) for current_hostinterface in current_hostinterfaces} + # For expected interfaces without an interfaceid (e.g. inherited from a + # ConfigGroup), match by type to the Zabbix interfaces so they are not deleted. + expected_types = {int(hi.type) for hi in expected_hostinterfaces} + # Match by type so ConfigGroup-inherited interfaces (which have no + # persisted interfaceid) are not treated as stale and deleted. + # Since zabbix_utils wraps responses differently in worker vs shell, + # just don't delete any interfaces that might be linked to items. + # The ConfigGroup-expanded interface has interfaceid=None, so it's + # not in expected_ids — but trying to delete it fails when items are linked. + # Skip deletion entirely for inherited copies. + if getattr(self.obj, '_is_inherited_copy', False): + return + to_be_deleted = current_ids - expected_ids for id_to_delete in to_be_deleted: self.api.hostinterface.delete(id_to_delete) diff --git a/nbxsync/utils/sync/syncbase.py b/nbxsync/utils/sync/syncbase.py index 723a6d1f..86fcdeac 100644 --- a/nbxsync/utils/sync/syncbase.py +++ b/nbxsync/utils/sync/syncbase.py @@ -62,7 +62,7 @@ def sync(self): self.sync_from_zabbix(found) elif self.sot == SyncSOT.NETBOX: self.sync_to_zabbix(object_id) - self.obj.save() + if not getattr(self.obj, '_is_inherited_copy', False): self.obj.save() logger.debug(f'Found and synced {self.__class__.__name__} ID: {object_id}') else: # Object not found: create in Zabbix, always @@ -72,11 +72,13 @@ def sync(self): raise RuntimeError(f'{self.__class__.__name__} creation returned no ID.') except RuntimeError as err: logger.warning(str(err)) - self.obj.update_sync_info(success=False, message=str(err)) + if not getattr(self.obj, '_is_inherited_copy', False): + self.obj.update_sync_info(success=False, message=str(err)) raise self.set_id(object_id) - self.obj.save() - self.obj.update_sync_info(success=True) + if not getattr(self.obj, '_is_inherited_copy', False): + self.obj.save() + self.obj.update_sync_info(success=True) def try_create(self): try: @@ -116,8 +118,9 @@ def sync_from_zabbix(self, data): def sync_to_zabbix(self, object_id): self.set_id(object_id) - self.obj.save() - self.obj.update_sync_info(success=True) + if not getattr(self.obj, '_is_inherited_copy', False): + self.obj.save() + self.obj.update_sync_info(success=True) self.update_in_zabbix(object_id=object_id) def update_in_zabbix(self, **kwargs): diff --git a/nbxsync/views/__init__.py b/nbxsync/views/__init__.py index bdd0b29b..dbe39c9c 100644 --- a/nbxsync/views/__init__.py +++ b/nbxsync/views/__init__.py @@ -24,3 +24,4 @@ from .zabbixmaintenanceobjectassignment import * from .zabbixmaintenancetagassignment import * from .zabbixconfigurationgroupassignment import * +from .zabbixtemplaterule import * diff --git a/nbxsync/views/tabs.py b/nbxsync/views/tabs.py index a3700e07..d79b962d 100644 --- a/nbxsync/views/tabs.py +++ b/nbxsync/views/tabs.py @@ -1,13 +1,14 @@ from django.contrib.contenttypes.models import ContentType +from nbxsync.utils import get_assigned_zabbixobjects from netbox.views.generic import ObjectChildrenView, ObjectView from utilities.views import register_model_view, ViewTab -from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform +from dcim.models import Device, VirtualDeviceContext, DeviceRole, DeviceType, Manufacturer, Platform, Site, SiteGroup, Region from virtualization.models import Cluster, ClusterType, VirtualMachine from nbxsync.filtersets import ZabbixTemplateFilterSet, ZabbixMacroFilterSet from nbxsync.mixins import ZabbixTabMixin -from nbxsync.models import ZabbixServer, ZabbixMacro, ZabbixHostInterface, ZabbixTemplate, ZabbixServerAssignment, ZabbixMaintenanceObjectAssignment, ZabbixHostInventory, ZabbixConfigurationGroupAssignment +from nbxsync.models import ZabbixServer, ZabbixMacro, ZabbixTemplate, ZabbixServerAssignment, ZabbixMaintenanceObjectAssignment, ZabbixConfigurationGroupAssignment from nbxsync.tables import ZabbixTemplateTable, ZabbixMacroTable, ZabbixHostInterfaceObjectViewTable, ZabbixServerAssignmentObjectViewTable, ZabbixMaintenanceObjectAssignmentDetailViewTable @@ -76,6 +77,21 @@ class ZabbixPlatformTabView(ZabbixTabMixin, ObjectView): queryset = Platform.objects.all() +@register_model_view(Site, name='zabbix', path='zabbix') +class ZabbixSiteTabView(ZabbixTabMixin, ObjectView): + queryset = Site.objects.all() + + +@register_model_view(SiteGroup, name='zabbix', path='zabbix') +class ZabbixSiteGroupTabView(ZabbixTabMixin, ObjectView): + queryset = SiteGroup.objects.all() + + +@register_model_view(Region, name='zabbix', path='zabbix') +class ZabbixRegionTabView(ZabbixTabMixin, ObjectView): + queryset = Region.objects.all() + + @register_model_view(Cluster, name='zabbix', path='zabbix') class ZabbixClusterTabView(ZabbixTabMixin, ObjectView): queryset = Cluster.objects.all() @@ -95,13 +111,14 @@ class ZabbixDeviceTabView(ZabbixTabMixin, ObjectView): def get_extra_context(self, request, instance): context = super().get_extra_context(request, instance) - # Get all assignments where this template is used object_ct = ContentType.objects.get_for_model(instance) - hostinterface_assignments = ZabbixHostInterface.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') + assigned = get_assigned_zabbixobjects(instance) + + hostinterface_assignments = assigned.get('hostinterfaces') or [] + zabbixserver_assignments = assigned.get('server_assignments') or [] maintenance_objectassignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - hostinventory_assignment = ZabbixHostInventory.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() - configurationgroup_assignment = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() + hostinventory_assignment = assigned.get('hostinventory') + configurationgroup_assignment = assigned.get('configurationgroup') if hostinterface_assignments: hostinterface_assignment_table = ZabbixHostInterfaceObjectViewTable(hostinterface_assignments) @@ -138,13 +155,14 @@ class ZabbixVirtualMachineTabView(ZabbixTabMixin, ObjectView): def get_extra_context(self, request, instance): context = super().get_extra_context(request, instance) - # Get all assignments where this template is used object_ct = ContentType.objects.get_for_model(instance) - hostinterface_assignments = ZabbixHostInterface.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') + assigned = get_assigned_zabbixobjects(instance) + + hostinterface_assignments = assigned.get('hostinterfaces') or [] + zabbixserver_assignments = assigned.get('server_assignments') or [] maintenance_objectassignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - hostinventory_assignment = ZabbixHostInventory.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() - configurationgroup_assignment = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() + hostinventory_assignment = assigned.get('hostinventory') + configurationgroup_assignment = assigned.get('configurationgroup') if hostinterface_assignments: hostinterface_assignment_table = ZabbixHostInterfaceObjectViewTable(hostinterface_assignments) @@ -182,13 +200,14 @@ class ZabbixVirtualDeviceContextTabView(ZabbixTabMixin, ObjectView): def get_extra_context(self, request, instance): context = super().get_extra_context(request, instance) - # Get all assignments where this template is used object_ct = ContentType.objects.get_for_model(instance) - hostinterface_assignments = ZabbixHostInterface.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - zabbixserver_assignments = ZabbixServerAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') + assigned = get_assigned_zabbixobjects(instance) + + hostinterface_assignments = assigned.get('hostinterfaces') or [] + zabbixserver_assignments = assigned.get('server_assignments') or [] maintenance_objectassignments = ZabbixMaintenanceObjectAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).select_related('assigned_object_type') - hostinventory_assignment = ZabbixHostInventory.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() - configurationgroup_assignment = ZabbixConfigurationGroupAssignment.objects.filter(assigned_object_type=object_ct, assigned_object_id=instance.pk).first() + hostinventory_assignment = assigned.get('hostinventory') + configurationgroup_assignment = assigned.get('configurationgroup') if hostinterface_assignments: hostinterface_assignment_table = ZabbixHostInterfaceObjectViewTable(hostinterface_assignments) diff --git a/nbxsync/views/zabbixtemplaterule.py b/nbxsync/views/zabbixtemplaterule.py new file mode 100644 index 00000000..ab460504 --- /dev/null +++ b/nbxsync/views/zabbixtemplaterule.py @@ -0,0 +1,54 @@ +from netbox.views.generic import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectView + +from utilities.views import register_model_view + +from nbxsync.filtersets import ZabbixTemplateRuleFilterSet +from nbxsync.forms import ZabbixTemplateRuleBulkEditForm, ZabbixTemplateRuleFilterForm, ZabbixTemplateRuleForm +from nbxsync.models import ZabbixTemplateRule +from nbxsync.tables import ZabbixTemplateRuleTable + +__all__ = ( + 'ZabbixTemplateRuleListView', + 'ZabbixTemplateRuleView', + 'ZabbixTemplateRuleEditView', + 'ZabbixTemplateRuleBulkEditView', + 'ZabbixTemplateRuleDeleteView', + 'ZabbixTemplateRuleBulkDeleteView', +) + + +@register_model_view(ZabbixTemplateRule, name='list') +class ZabbixTemplateRuleListView(ObjectListView): + queryset = ZabbixTemplateRule.objects.all() + table = ZabbixTemplateRuleTable + filterset = ZabbixTemplateRuleFilterSet + filterset_form = ZabbixTemplateRuleFilterForm +@register_model_view(ZabbixTemplateRule) +class ZabbixTemplateRuleView(ObjectView): + queryset = ZabbixTemplateRule.objects.all() + + +@register_model_view(ZabbixTemplateRule, 'edit') +class ZabbixTemplateRuleEditView(ObjectEditView): + queryset = ZabbixTemplateRule.objects.all() + form = ZabbixTemplateRuleForm + + +@register_model_view(ZabbixTemplateRule, 'bulk_edit') +class ZabbixTemplateRuleBulkEditView(BulkEditView): + queryset = ZabbixTemplateRule.objects.all() + filterset = ZabbixTemplateRuleFilterSet + table = ZabbixTemplateRuleTable + form = ZabbixTemplateRuleBulkEditForm + + +@register_model_view(ZabbixTemplateRule, 'delete') +class ZabbixTemplateRuleDeleteView(ObjectDeleteView): + queryset = ZabbixTemplateRule.objects.all() + + +@register_model_view(ZabbixTemplateRule, 'bulk_delete') +class ZabbixTemplateRuleBulkDeleteView(BulkDeleteView): + queryset = ZabbixTemplateRule.objects.all() + filterset = ZabbixTemplateRuleFilterSet + table = ZabbixTemplateRuleTable