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