Преглед изворни кода

Merge branch 'main' into feature

Jeremy Stretch пре 7 месеци
родитељ
комит
5f8a4f6c43
79 измењених фајлова са 3609 додато и 2757 уклоњено
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      README.md
  4. 9 4
      base_requirements.txt
  5. 1 0
      docs/configuration/system.md
  6. 1 1
      docs/development/getting-started.md
  7. 1 1
      docs/features/background-jobs.md
  8. 1 1
      docs/installation/upgrading.md
  9. BIN
      docs/media/installation/upgrade_paths.png
  10. 2 1
      docs/plugins/development/background-jobs.md
  11. 22 0
      docs/release-notes/version-4.3.md
  12. 12 2
      netbox/core/exceptions.py
  13. 3 4
      netbox/core/models/jobs.py
  14. 2 2
      netbox/dcim/forms/bulk_import.py
  15. 2 0
      netbox/dcim/graphql/types.py
  16. 2 1
      netbox/dcim/migrations/0206_load_module_type_profiles.py
  17. 44 0
      netbox/dcim/migrations/0210_devicerole_uniqueness.py
  18. 15 0
      netbox/dcim/migrations/initial_data/module_type_profiles/expansion_card.json
  19. 22 0
      netbox/dcim/models/devices.py
  20. 6 2
      netbox/dcim/tables/devices.py
  21. 16 4
      netbox/dcim/tables/sites.py
  22. 72 17
      netbox/dcim/tests/test_views.py
  23. 15 8
      netbox/extras/conditions.py
  24. 1 1
      netbox/extras/events.py
  25. 15 1
      netbox/extras/lookups.py
  26. 10 2
      netbox/extras/models/models.py
  27. 21 10
      netbox/extras/tests/test_conditions.py
  28. 15 0
      netbox/ipam/models/ip.py
  29. 8 1
      netbox/netbox/jobs.py
  30. 28 0
      netbox/netbox/navigation/__init__.py
  31. 28 2
      netbox/netbox/plugins/navigation.py
  32. 5 3
      netbox/netbox/search/backends.py
  33. 11 0
      netbox/netbox/tests/test_jobs.py
  34. 0 0
      netbox/project-static/dist/netbox.css
  35. 0 0
      netbox/project-static/dist/netbox.js
  36. 0 0
      netbox/project-static/dist/netbox.js.map
  37. 9 9
      netbox/project-static/package.json
  38. 4 0
      netbox/project-static/styles/custom/racks.scss
  39. 1 0
      netbox/project-static/styles/netbox.scss
  40. 501 291
      netbox/project-static/yarn.lock
  41. 2 2
      netbox/release.yaml
  42. 12 1
      netbox/templates/dcim/inc/rack_elevation.html
  43. 1 1
      netbox/templates/extras/customfieldchoiceset.html
  44. 1 5
      netbox/templates/inc/filter_list.html
  45. 5 1
      netbox/tenancy/tables/contacts.py
  46. 5 1
      netbox/tenancy/tables/tenants.py
  47. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  48. 176 158
      netbox/translations/cs/LC_MESSAGES/django.po
  49. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  50. 176 155
      netbox/translations/da/LC_MESSAGES/django.po
  51. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  52. 175 153
      netbox/translations/de/LC_MESSAGES/django.po
  53. 211 198
      netbox/translations/en/LC_MESSAGES/django.po
  54. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  55. 177 154
      netbox/translations/es/LC_MESSAGES/django.po
  56. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  57. 172 153
      netbox/translations/fr/LC_MESSAGES/django.po
  58. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  59. 177 154
      netbox/translations/it/LC_MESSAGES/django.po
  60. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  61. 179 158
      netbox/translations/ja/LC_MESSAGES/django.po
  62. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  63. 176 154
      netbox/translations/nl/LC_MESSAGES/django.po
  64. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  65. 146 145
      netbox/translations/pl/LC_MESSAGES/django.po
  66. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  67. 179 156
      netbox/translations/pt/LC_MESSAGES/django.po
  68. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  69. 178 155
      netbox/translations/ru/LC_MESSAGES/django.po
  70. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  71. 178 155
      netbox/translations/tr/LC_MESSAGES/django.po
  72. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  73. 177 154
      netbox/translations/uk/LC_MESSAGES/django.po
  74. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  75. 179 158
      netbox/translations/zh/LC_MESSAGES/django.po
  76. 2 2
      netbox/utilities/templates/navigation/menu.html
  77. 6 2
      netbox/wireless/tables/wirelesslan.py
  78. 1 1
      pyproject.toml
  79. 10 10
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/01-feature_request.yaml

@@ -15,7 +15,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.3
+      placeholder: v4.3.4
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/02-bug_report.yaml

@@ -27,7 +27,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.3
+      placeholder: v4.3.4
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
README.md

@@ -6,7 +6,7 @@
   <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
   <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
   <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
   <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
-  <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
+  <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
   <p>
   <p>
     <strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
     <strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
     <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
     <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |

+ 9 - 4
base_requirements.txt

@@ -14,6 +14,10 @@ django-debug-toolbar
 # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
 # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
 django-filter
 django-filter
 
 
+# Django Debug Toolbar extension for GraphiQL
+# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
+django-graphiql-debug-toolbar
+
 # HTMX utilities for Django
 # HTMX utilities for Django
 # https://django-htmx.readthedocs.io/en/latest/changelog.html
 # https://django-htmx.readthedocs.io/en/latest/changelog.html
 django-htmx
 django-htmx
@@ -108,6 +112,7 @@ nh3
 
 
 # Fork of PIL (Python Imaging Library) for image processing
 # Fork of PIL (Python Imaging Library) for image processing
 # https://github.com/python-pillow/Pillow/releases
 # https://github.com/python-pillow/Pillow/releases
+# https://pillow.readthedocs.io/en/stable/releasenotes/
 Pillow
 Pillow
 
 
 # PostgreSQL database adapter for Python
 # PostgreSQL database adapter for Python
@@ -126,14 +131,14 @@ requests
 # https://github.com/rq/rq/blob/master/CHANGES.md
 # https://github.com/rq/rq/blob/master/CHANGES.md
 rq
 rq
 
 
-# Social authentication framework
-# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
-social-auth-core
-
 # Django app for social-auth-core
 # Django app for social-auth-core
 # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
 # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
 social-auth-app-django
 social-auth-app-django
 
 
+# Social authentication framework
+# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
+social-auth-core
+
 # Strawberry GraphQL
 # Strawberry GraphQL
 # https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
 # https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
 strawberry-graphql
 strawberry-graphql

+ 1 - 0
docs/configuration/system.md

@@ -158,6 +158,7 @@ LOGGING = {
 * `netbox.<app>.<model>` - Generic form for model-specific log messages
 * `netbox.<app>.<model>` - Generic form for model-specific log messages
 * `netbox.auth.*` - Authentication events
 * `netbox.auth.*` - Authentication events
 * `netbox.api.views.*` - Views which handle business logic for the REST API
 * `netbox.api.views.*` - Views which handle business logic for the REST API
+* `netbox.event_rules` - Event rules
 * `netbox.jobs.*` - Background jobs
 * `netbox.jobs.*` - Background jobs
 * `netbox.reports.*` - Report execution (`module.name`)
 * `netbox.reports.*` - Report execution (`module.name`)
 * `netbox.scripts.*` - Custom script execution (`module.name`)
 * `netbox.scripts.*` - Custom script execution (`module.name`)

+ 1 - 1
docs/development/getting-started.md

@@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
 
 
 ## Populating Demo Data
 ## Populating Demo Data
 
 
-Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
+Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
 
 
 The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
 The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
 
 

+ 1 - 1
docs/features/background-jobs.md

@@ -2,9 +2,9 @@
 
 
 NetBox includes the ability to execute certain functions as background tasks. These include:
 NetBox includes the ability to execute certain functions as background tasks. These include:
 
 
-* [Report](../customization/reports.md) execution
 * [Custom script](../customization/custom-scripts.md) execution
 * [Custom script](../customization/custom-scripts.md) execution
 * Synchronization of [remote data sources](../integrations/synchronized-data.md)
 * Synchronization of [remote data sources](../integrations/synchronized-data.md)
+* Housekeeping tasks
 
 
 Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
 Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
 
 

+ 1 - 1
docs/installation/upgrading.md

@@ -135,7 +135,7 @@ Check out the desired release by specifying its tag. For example:
 
 
 ```
 ```
 cd /opt/netbox && \
 cd /opt/netbox && \
-sudo git fetch && \
+sudo git fetch --tags && \
 sudo git checkout v4.2.7
 sudo git checkout v4.2.7
 ```
 ```
 
 

BIN
docs/media/installation/upgrade_paths.png


+ 2 - 1
docs/plugins/development/background-jobs.md

@@ -15,7 +15,6 @@ A background job implements a basic [Job](../../models/core/job.md) executor for
 ```python title="jobs.py"
 ```python title="jobs.py"
 from netbox.jobs import JobRunner
 from netbox.jobs import JobRunner
 
 
-
 class MyTestJob(JobRunner):
 class MyTestJob(JobRunner):
     class Meta:
     class Meta:
         name = "My Test Job"
         name = "My Test Job"
@@ -25,6 +24,8 @@ class MyTestJob(JobRunner):
         # your logic goes here
         # your logic goes here
 ```
 ```
 
 
+Completed jobs will have their status updated to "completed" by default, or "errored" if an unhandled exception was raised by the `run()` method. To intentionally mark a job as failed, raise the `core.exceptions.JobFailed` exception. (Note that "failed" differs from "errored" in that a failure may be expected under certain conditions, whereas an error is not.)
+
 You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
 You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
 
 
 !!! tip
 !!! tip

+ 22 - 0
docs/release-notes/version-4.3.md

@@ -1,5 +1,27 @@
 # NetBox v4.3
 # NetBox v4.3
 
 
+## v4.3.4 (2025-07-15)
+
+### Enhancements
+
+* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search
+* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations
+* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards
+* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links
+* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response
+
+### Bug Fixes
+
+* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms
+* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions
+* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment
+* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed
+* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs
+* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects
+* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field
+
+---
+
 ## v4.3.3 (2025-06-26)
 ## v4.3.3 (2025-06-26)
 
 
 ### Enhancements
 ### Enhancements

+ 12 - 2
netbox/core/exceptions.py

@@ -1,9 +1,19 @@
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 
 
+__all__ = (
+    'IncompatiblePluginError',
+    'JobFailed',
+    'SyncError',
+)
 
 
-class SyncError(Exception):
+
+class IncompatiblePluginError(ImproperlyConfigured):
     pass
     pass
 
 
 
 
-class IncompatiblePluginError(ImproperlyConfigured):
+class JobFailed(Exception):
+    pass
+
+
+class SyncError(Exception):
     pass
     pass

+ 3 - 4
netbox/core/models/jobs.py

@@ -201,15 +201,14 @@ class Job(models.Model):
         """
         """
         Mark the job as completed, optionally specifying a particular termination status.
         Mark the job as completed, optionally specifying a particular termination status.
         """
         """
-        valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
-        if status not in valid_statuses:
+        if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
             raise ValueError(
             raise ValueError(
                 _("Invalid status for job termination. Choices are: {choices}").format(
                 _("Invalid status for job termination. Choices are: {choices}").format(
-                    choices=', '.join(valid_statuses)
+                    choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES)
                 )
                 )
             )
             )
 
 
-        # Mark the job as completed
+        # Set the job's status and completion time
         self.status = status
         self.status = status
         if error:
         if error:
             self.error = error
             self.error = error

+ 2 - 2
netbox/dcim/forms/bulk_import.py

@@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
-            'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments',
-            'tags',
+            'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
+            'comments', 'tags'
         ]
         ]
 
 
 
 

+ 2 - 0
netbox/dcim/graphql/types.py

@@ -33,6 +33,7 @@ if TYPE_CHECKING:
     from tenancy.graphql.types import TenantType
     from tenancy.graphql.types import TenantType
     from users.graphql.types import UserType
     from users.graphql.types import UserType
     from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
     from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
+    from vpn.graphql.types import L2VPNTerminationType
     from wireless.graphql.types import WirelessLANType, WirelessLinkType
     from wireless.graphql.types import WirelessLANType, WirelessLinkType
 
 
 __all__ = (
 __all__ = (
@@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
     primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
     primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
     qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
     vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
+    l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
 
 
     vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
     vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]

+ 2 - 1
netbox/dcim/migrations/0206_load_module_type_profiles.py

@@ -19,7 +19,8 @@ def load_initial_data(apps, schema_editor):
         'gpu',
         'gpu',
         'hard_disk',
         'hard_disk',
         'memory',
         'memory',
-        'power_supply'
+        'power_supply',
+        'expansion_card'
     )
     )
 
 
     for name in initial_profiles:
     for name in initial_profiles:

+ 44 - 0
netbox/dcim/migrations/0210_devicerole_uniqueness.py

@@ -0,0 +1,44 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0209_interface_tx_power_negative'),
+        ('extras', '0129_fix_script_paths'),
+    ]
+
+    operations = [
+        migrations.AddConstraint(
+            model_name='devicerole',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'name'),
+                name='dcim_devicerole_parent_name'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='devicerole',
+            constraint=models.UniqueConstraint(
+                condition=models.Q(('parent__isnull', True)),
+                fields=('name',),
+                name='dcim_devicerole_name',
+                violation_error_message='A top-level device role with this name already exists.'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='devicerole',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'slug'),
+                name='dcim_devicerole_parent_slug'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='devicerole',
+            constraint=models.UniqueConstraint(
+                condition=models.Q(('parent__isnull', True)),
+                fields=('slug',),
+                name='dcim_devicerole_slug',
+                violation_error_message='A top-level device role with this slug already exists.'
+            ),
+        ),
+    ]

+ 15 - 0
netbox/dcim/migrations/initial_data/module_type_profiles/expansion_card.json

@@ -0,0 +1,15 @@
+{
+    "name": "Expansion card",
+    "schema": {
+        "properties": {
+            "connector_type": {
+                "type": "string",
+                "description": "Connector type e.g. PCIe x4"
+            },
+            "bandwidth": {
+                "type": "integer",
+                "description": "Total Bandwidth for this module"
+            }
+        }
+    }
+}

+ 22 - 0
netbox/dcim/models/devices.py

@@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
+        constraints = (
+            models.UniqueConstraint(
+                fields=('parent', 'name'),
+                name='%(app_label)s_%(class)s_parent_name'
+            ),
+            models.UniqueConstraint(
+                fields=('name',),
+                name='%(app_label)s_%(class)s_name',
+                condition=Q(parent__isnull=True),
+                violation_error_message=_("A top-level device role with this name already exists.")
+            ),
+            models.UniqueConstraint(
+                fields=('parent', 'slug'),
+                name='%(app_label)s_%(class)s_parent_slug'
+            ),
+            models.UniqueConstraint(
+                fields=('slug',),
+                name='%(app_label)s_%(class)s_slug',
+                condition=Q(parent__isnull=True),
+                violation_error_message=_("A top-level device role with this slug already exists.")
+            ),
+        )
         verbose_name = _('device role')
         verbose_name = _('device role')
         verbose_name_plural = _('device roles')
         verbose_name_plural = _('device roles')
 
 

+ 6 - 2
netbox/dcim/tables/devices.py

@@ -63,6 +63,10 @@ class DeviceRoleTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     device_count = columns.LinkedCountColumn(
     device_count = columns.LinkedCountColumn(
         viewname='dcim:device_list',
         viewname='dcim:device_list',
         url_params={'role_id': 'pk'},
         url_params={'role_id': 'pk'},
@@ -88,8 +92,8 @@ class DeviceRoleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = models.DeviceRole
         model = models.DeviceRole
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
-            'slug', 'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template',
+            'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
 
 

+ 16 - 4
netbox/dcim/tables/sites.py

@@ -24,6 +24,10 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     site_count = columns.LinkedCountColumn(
     site_count = columns.LinkedCountColumn(
         viewname='dcim:site_list',
         viewname='dcim:site_list',
         url_params={'region_id': 'pk'},
         url_params={'region_id': 'pk'},
@@ -39,7 +43,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Region
         model = Region
         fields = (
         fields = (
-            'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
+            'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
             'created', 'last_updated', 'actions',
             'created', 'last_updated', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'site_count', 'description')
         default_columns = ('pk', 'name', 'site_count', 'description')
@@ -54,6 +58,10 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     site_count = columns.LinkedCountColumn(
     site_count = columns.LinkedCountColumn(
         viewname='dcim:site_list',
         viewname='dcim:site_list',
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
@@ -69,7 +77,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = SiteGroup
         model = SiteGroup
         fields = (
         fields = (
-            'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
+            'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
             'created', 'last_updated', 'actions',
             'created', 'last_updated', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'site_count', 'description')
         default_columns = ('pk', 'name', 'site_count', 'description')
@@ -135,6 +143,10 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     site = tables.Column(
     site = tables.Column(
         verbose_name=_('Site'),
         verbose_name=_('Site'),
         linkify=True
         linkify=True
@@ -170,8 +182,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Location
         model = Location
         fields = (
         fields = (
-            'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
-            'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count',
+            'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
             'vlangroup_count',
             'vlangroup_count',
         )
         )
         default_columns = (
         default_columns = (

+ 72 - 17
netbox/dcim/tests/test_views.py

@@ -3,7 +3,7 @@ from decimal import Decimal
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
 import yaml
 import yaml
-from django.test import override_settings
+from django.test import override_settings, tag
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import EUI
 from netaddr import EUI
 
 
@@ -1000,18 +1000,7 @@ inventory-items:
         self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
         self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
 
 
 
 
-# TODO: Change base class to PrimaryObjectViewTestCase
-# Blocked by absence of bulk import view for ModuleTypes
-class ModuleTypeTestCase(
-    ViewTestCases.GetObjectViewTestCase,
-    ViewTestCases.GetObjectChangelogViewTestCase,
-    ViewTestCases.CreateObjectViewTestCase,
-    ViewTestCases.EditObjectViewTestCase,
-    ViewTestCases.DeleteObjectViewTestCase,
-    ViewTestCases.ListObjectsViewTestCase,
-    ViewTestCases.BulkEditObjectsViewTestCase,
-    ViewTestCases.BulkDeleteObjectsViewTestCase
-):
+class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ModuleType
     model = ModuleType
 
 
     @classmethod
     @classmethod
@@ -1023,7 +1012,7 @@ class ModuleTypeTestCase(
         )
         )
         Manufacturer.objects.bulk_create(manufacturers)
         Manufacturer.objects.bulk_create(manufacturers)
 
 
-        ModuleType.objects.bulk_create([
+        module_types = ModuleType.objects.bulk_create([
             ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
             ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
             ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
             ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
             ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
             ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
@@ -1031,6 +1020,8 @@ class ModuleTypeTestCase(
 
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
+        fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
+
         cls.form_data = {
         cls.form_data = {
             'manufacturer': manufacturers[1].pk,
             'manufacturer': manufacturers[1].pk,
             'model': 'Device Type X',
             'model': 'Device Type X',
@@ -1044,6 +1035,70 @@ class ModuleTypeTestCase(
             'part_number': '456DEF',
             'part_number': '456DEF',
         }
         }
 
 
+        cls.csv_data = (
+            "manufacturer,model,part_number,comments,profile",
+            f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
+        )
+
+        cls.csv_update_data = (
+            "id,model",
+            f"{module_types[0].id},test model",
+        )
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_bulk_update_objects_with_permission(self):
+        self.add_permissions(
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
+        )
+
+        # run base test
+        super().test_bulk_update_objects_with_permission()
+
+    @tag('regression')
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_import_objects_with_permission(self):
+        self.add_permissions(
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
+        )
+
+        # run base test
+        super().test_bulk_import_objects_with_permission()
+
+        # TODO: remove extra regression asserts once parent test supports testing all import fields
+        fan_module_type = ModuleType.objects.get(part_number='generic-fan')
+        fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
+
+        assert fan_module_type.profile == fan_module_type_profile
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_import_objects_with_constrained_permission(self):
+        self.add_permissions(
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
+        )
+
+        super().test_bulk_import_objects_with_constrained_permission()
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_consoleports(self):
     def test_moduletype_consoleports(self):
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
@@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 
 
         cls.csv_data = (
         cls.csv_data = (
             "name,slug,color",
             "name,slug,color",
-            "Device Role 4,device-role-4,ff0000",
-            "Device Role 5,device-role-5,00ff00",
-            "Device Role 6,device-role-6,0000ff",
+            "Device Role 6,device-role-6,ff0000",
+            "Device Role 7,device-role-7,00ff00",
+            "Device Role 8,device-role-8,0000ff",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (

+ 15 - 8
netbox/extras/conditions.py

@@ -1,13 +1,14 @@
 import functools
 import functools
+import operator
 import re
 import re
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 __all__ = (
 __all__ = (
     'Condition',
     'Condition',
     'ConditionSet',
     'ConditionSet',
+    'InvalidCondition',
 )
 )
 
 
-
 AND = 'and'
 AND = 'and'
 OR = 'or'
 OR = 'or'
 
 
@@ -19,6 +20,10 @@ def is_ruleset(data):
     return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
     return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
 
 
 
 
+class InvalidCondition(Exception):
+    pass
+
+
 class Condition:
 class Condition:
     """
     """
     An individual conditional rule that evaluates a single attribute and its value.
     An individual conditional rule that evaluates a single attribute and its value.
@@ -61,6 +66,7 @@ class Condition:
 
 
         self.attr = attr
         self.attr = attr
         self.value = value
         self.value = value
+        self.op = op
         self.eval_func = getattr(self, f'eval_{op}')
         self.eval_func = getattr(self, f'eval_{op}')
         self.negate = negate
         self.negate = negate
 
 
@@ -70,16 +76,17 @@ class Condition:
         """
         """
         def _get(obj, key):
         def _get(obj, key):
             if isinstance(obj, list):
             if isinstance(obj, list):
-                return [dict.get(i, key) for i in obj]
-
-            return dict.get(obj, key)
+                return [operator.getitem(item or {}, key) for item in obj]
+            return operator.getitem(obj or {}, key)
 
 
         try:
         try:
             value = functools.reduce(_get, self.attr.split('.'), data)
             value = functools.reduce(_get, self.attr.split('.'), data)
-        except TypeError:
-            # Invalid key path
-            value = None
-        result = self.eval_func(value)
+        except KeyError:
+            raise InvalidCondition(f"Invalid key path: {self.attr}")
+        try:
+            result = self.eval_func(value)
+        except TypeError as e:
+            raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
 
 
         if self.negate:
         if self.negate:
             return not result
             return not result

+ 1 - 1
netbox/extras/events.py

@@ -192,5 +192,5 @@ def flush_events(events):
             try:
             try:
                 func = import_string(name)
                 func = import_string(name)
                 func(events)
                 func(events)
-            except Exception as e:
+            except ImportError as e:
                 logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
                 logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

+ 15 - 1
netbox/extras/lookups.py

@@ -18,9 +18,22 @@ class Empty(Lookup):
             return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
             return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
 
 
 
 
+class NetHost(Lookup):
+    """
+    Similar to ipam.lookups.NetHost, but casts the field to INET.
+    """
+    lookup_name = 'net_host'
+
+    def as_sql(self, qn, connection):
+        lhs, lhs_params = self.process_lhs(qn, connection)
+        rhs, rhs_params = self.process_rhs(qn, connection)
+        params = lhs_params + rhs_params
+        return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params
+
+
 class NetContainsOrEquals(Lookup):
 class NetContainsOrEquals(Lookup):
     """
     """
-    This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
+    Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET.
     """
     """
     lookup_name = 'net_contains_or_equals'
     lookup_name = 'net_contains_or_equals'
 
 
@@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup):
 
 
 
 
 CharField.register_lookup(Empty)
 CharField.register_lookup(Empty)
+CachedValueField.register_lookup(NetHost)
 CachedValueField.register_lookup(NetContainsOrEquals)
 CachedValueField.register_lookup(NetContainsOrEquals)

+ 10 - 2
netbox/extras/models/models.py

@@ -13,7 +13,7 @@ from rest_framework.utils.encoders import JSONEncoder
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
-from extras.conditions import ConditionSet
+from extras.conditions import ConditionSet, InvalidCondition
 from extras.constants import *
 from extras.constants import *
 from extras.utils import image_upload
 from extras.utils import image_upload
 from extras.models.mixins import RenderTemplateMixin
 from extras.models.mixins import RenderTemplateMixin
@@ -142,7 +142,15 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
         if not self.conditions:
         if not self.conditions:
             return True
             return True
 
 
-        return ConditionSet(self.conditions).eval(data)
+        logger = logging.getLogger('netbox.event_rules')
+
+        try:
+            result = ConditionSet(self.conditions).eval(data)
+            logger.debug(f'{self.name}: Evaluated as {result}')
+            return result
+        except InvalidCondition as e:
+            logger.error(f"{self.name}: Evaluation failed. {e}")
+            return False
 
 
 
 
 class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
 class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):

+ 21 - 10
netbox/extras/tests/test_conditions.py

@@ -4,7 +4,7 @@ from django.test import TestCase
 from core.events import *
 from core.events import *
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
-from extras.conditions import Condition, ConditionSet
+from extras.conditions import Condition, ConditionSet, InvalidCondition
 from extras.events import serialize_for_event
 from extras.events import serialize_for_event
 from extras.forms import EventRuleForm
 from extras.forms import EventRuleForm
 from extras.models import EventRule, Webhook
 from extras.models import EventRule, Webhook
@@ -12,16 +12,11 @@ from extras.models import EventRule, Webhook
 
 
 class ConditionTestCase(TestCase):
 class ConditionTestCase(TestCase):
 
 
-    def test_dotted_path_access(self):
-        c = Condition('a.b.c', 1, 'eq')
-        self.assertTrue(c.eval({'a': {'b': {'c': 1}}}))
-        self.assertFalse(c.eval({'a': {'b': {'c': 2}}}))
-        self.assertFalse(c.eval({'a': {'b': {'x': 1}}}))
-
     def test_undefined_attr(self):
     def test_undefined_attr(self):
         c = Condition('x', 1, 'eq')
         c = Condition('x', 1, 'eq')
-        self.assertFalse(c.eval({}))
         self.assertTrue(c.eval({'x': 1}))
         self.assertTrue(c.eval({'x': 1}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({})
 
 
     #
     #
     # Validation tests
     # Validation tests
@@ -37,10 +32,13 @@ class ConditionTestCase(TestCase):
             # dict type is unsupported
             # dict type is unsupported
             Condition('x', 1, dict())
             Condition('x', 1, dict())
 
 
-    def test_invalid_op_type(self):
+    def test_invalid_op_types(self):
         with self.assertRaises(ValueError):
         with self.assertRaises(ValueError):
             # 'gt' supports only numeric values
             # 'gt' supports only numeric values
             Condition('x', 'foo', 'gt')
             Condition('x', 'foo', 'gt')
+        with self.assertRaises(ValueError):
+            # 'in' supports only iterable values
+            Condition('x', 123, 'in')
 
 
     #
     #
     # Nested attrs tests
     # Nested attrs tests
@@ -50,7 +48,10 @@ class ConditionTestCase(TestCase):
         c = Condition('x.y.z', 1)
         c = Condition('x.y.z', 1)
         self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
         self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
         self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
         self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
-        self.assertFalse(c.eval({'a': {'b': {'c': 1}}}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'x': {'y': None}})
+        with self.assertRaises(InvalidCondition):
+            c.eval({'x': {'y': {'a': 1}}})
 
 
     #
     #
     # Operator tests
     # Operator tests
@@ -74,23 +75,31 @@ class ConditionTestCase(TestCase):
         c = Condition('x', 1, 'gt')
         c = Condition('x', 1, 'gt')
         self.assertTrue(c.eval({'x': 2}))
         self.assertTrue(c.eval({'x': 2}))
         self.assertFalse(c.eval({'x': 1}))
         self.assertFalse(c.eval({'x': 1}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'x': 'foo'})  # Invalid type
 
 
     def test_gte(self):
     def test_gte(self):
         c = Condition('x', 1, 'gte')
         c = Condition('x', 1, 'gte')
         self.assertTrue(c.eval({'x': 2}))
         self.assertTrue(c.eval({'x': 2}))
         self.assertTrue(c.eval({'x': 1}))
         self.assertTrue(c.eval({'x': 1}))
         self.assertFalse(c.eval({'x': 0}))
         self.assertFalse(c.eval({'x': 0}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'x': 'foo'})  # Invalid type
 
 
     def test_lt(self):
     def test_lt(self):
         c = Condition('x', 2, 'lt')
         c = Condition('x', 2, 'lt')
         self.assertTrue(c.eval({'x': 1}))
         self.assertTrue(c.eval({'x': 1}))
         self.assertFalse(c.eval({'x': 2}))
         self.assertFalse(c.eval({'x': 2}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'x': 'foo'})  # Invalid type
 
 
     def test_lte(self):
     def test_lte(self):
         c = Condition('x', 2, 'lte')
         c = Condition('x', 2, 'lte')
         self.assertTrue(c.eval({'x': 1}))
         self.assertTrue(c.eval({'x': 1}))
         self.assertTrue(c.eval({'x': 2}))
         self.assertTrue(c.eval({'x': 2}))
         self.assertFalse(c.eval({'x': 3}))
         self.assertFalse(c.eval({'x': 3}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'x': 'foo'})  # Invalid type
 
 
     def test_in(self):
     def test_in(self):
         c = Condition('x', [1, 2, 3], 'in')
         c = Condition('x', [1, 2, 3], 'in')
@@ -106,6 +115,8 @@ class ConditionTestCase(TestCase):
         c = Condition('x', 1, 'contains')
         c = Condition('x', 1, 'contains')
         self.assertTrue(c.eval({'x': [1, 2, 3]}))
         self.assertTrue(c.eval({'x': [1, 2, 3]}))
         self.assertFalse(c.eval({'x': [2, 3, 4]}))
         self.assertFalse(c.eval({'x': [2, 3, 4]}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'x': 123})  # Invalid type
 
 
     def test_contains_negated(self):
     def test_contains_negated(self):
         c = Condition('x', 1, 'contains', negate=True)
         c = Condition('x', 1, 'contains', negate=True)

+ 15 - 0
netbox/ipam/models/ip.py

@@ -162,6 +162,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
             return self.prefix.version
             return self.prefix.version
         return None
         return None
 
 
+    @property
+    def ipv6_full(self):
+        if self.prefix and self.prefix.version == 6:
+            return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
+
     def get_child_prefixes(self):
     def get_child_prefixes(self):
         """
         """
         Return all Prefixes within this Aggregate
         Return all Prefixes within this Aggregate
@@ -330,6 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
     def mask_length(self):
     def mask_length(self):
         return self.prefix.prefixlen if self.prefix else None
         return self.prefix.prefixlen if self.prefix else None
 
 
+    @property
+    def ipv6_full(self):
+        if self.prefix and self.prefix.version == 6:
+            return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
+
     @property
     @property
     def depth(self):
     def depth(self):
         return self._depth
         return self._depth
@@ -808,6 +818,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
         self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
         self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
         self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
         self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
 
 
+    @property
+    def ipv6_full(self):
+        if self.address and self.address.version == 6:
+            return netaddr.IPAddress(self.address).format(netaddr.ipv6_full)
+
     def get_duplicates(self):
     def get_duplicates(self):
         return IPAddress.objects.filter(
         return IPAddress.objects.filter(
             vrf=self.vrf,
             vrf=self.vrf,

+ 8 - 1
netbox/netbox/jobs.py

@@ -9,6 +9,7 @@ from rq.timeouts import JobTimeoutException
 
 
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
 from core.events import JOB_COMPLETED, JOB_FAILED
 from core.events import JOB_COMPLETED, JOB_FAILED
+from core.exceptions import JobFailed
 from core.models import Job, ObjectType
 from core.models import Job, ObjectType
 from extras.models import Notification
 from extras.models import Notification
 from netbox.constants import ADVISORY_LOCK_KEYS
 from netbox.constants import ADVISORY_LOCK_KEYS
@@ -95,15 +96,21 @@ class JobRunner(ABC):
         This method is called by the Job Scheduler to handle the execution of all job commands. It will maintain the
         This method is called by the Job Scheduler to handle the execution of all job commands. It will maintain the
         job's metadata and handle errors. For periodic jobs, a new job is automatically scheduled using its `interval`.
         job's metadata and handle errors. For periodic jobs, a new job is automatically scheduled using its `interval`.
         """
         """
+        logger = logging.getLogger('netbox.jobs')
+
         try:
         try:
             job.start()
             job.start()
             cls(job).run(*args, **kwargs)
             cls(job).run(*args, **kwargs)
             job.terminate()
             job.terminate()
 
 
+        except JobFailed:
+            logger.warning(f"Job {job} failed")
+            job.terminate(status=JobStatusChoices.STATUS_FAILED)
+
         except Exception as e:
         except Exception as e:
             job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
             job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
             if type(e) is JobTimeoutException:
             if type(e) is JobTimeoutException:
-                logging.error(e)
+                logger.error(e)
 
 
         # If the executed job is a periodic job, schedule its next execution at the specified interval.
         # If the executed job is a periodic job, schedule its next execution at the specified interval.
         finally:
         finally:

+ 28 - 0
netbox/netbox/navigation/__init__.py

@@ -1,6 +1,8 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
 from typing import Sequence, Optional
 from typing import Sequence, Optional
 
 
+from django.urls import reverse_lazy
+
 
 
 __all__ = (
 __all__ = (
     'get_model_item',
     'get_model_item',
@@ -22,20 +24,46 @@ class MenuItemButton:
     link: str
     link: str
     title: str
     title: str
     icon_class: str
     icon_class: str
+    _url: Optional[str] = None
     permissions: Optional[Sequence[str]] = ()
     permissions: Optional[Sequence[str]] = ()
     color: Optional[str] = None
     color: Optional[str] = None
 
 
+    def __post_init__(self):
+        if self.link:
+            self._url = reverse_lazy(self.link)
+
+    @property
+    def url(self):
+        return self._url
+
+    @url.setter
+    def url(self, value):
+        self._url = value
+
 
 
 @dataclass
 @dataclass
 class MenuItem:
 class MenuItem:
 
 
     link: str
     link: str
     link_text: str
     link_text: str
+    _url: Optional[str] = None
     permissions: Optional[Sequence[str]] = ()
     permissions: Optional[Sequence[str]] = ()
     auth_required: Optional[bool] = False
     auth_required: Optional[bool] = False
     staff_only: Optional[bool] = False
     staff_only: Optional[bool] = False
     buttons: Optional[Sequence[MenuItemButton]] = ()
     buttons: Optional[Sequence[MenuItemButton]] = ()
 
 
+    def __post_init__(self):
+        if self.link:
+            self._url = reverse_lazy(self.link)
+
+    @property
+    def url(self):
+        return self._url
+
+    @url.setter
+    def url(self, value):
+        self._url = value
+
 
 
 @dataclass
 @dataclass
 class MenuGroup:
 class MenuGroup:

+ 28 - 2
netbox/netbox/plugins/navigation.py

@@ -1,3 +1,4 @@
+from django.urls import reverse_lazy
 from django.utils.text import slugify
 from django.utils.text import slugify
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -32,17 +33,23 @@ class PluginMenuItem:
     This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
     This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
     specifying additional link buttons that appear to the right of the item in the van menu.
     specifying additional link buttons that appear to the right of the item in the van menu.
 
 
-    Links are specified as Django reverse URL strings.
+    Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}.
+    Alternatively, a pre-generated url can be set on the object which will be rendered literally.
     Buttons are each specified as a list of PluginMenuButton instances.
     Buttons are each specified as a list of PluginMenuButton instances.
     """
     """
     permissions = []
     permissions = []
     buttons = []
     buttons = []
+    _url = None
 
 
-    def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None):
+    def __init__(
+        self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None
+    ):
         self.link = link
         self.link = link
         self.link_text = link_text
         self.link_text = link_text
         self.auth_required = auth_required
         self.auth_required = auth_required
         self.staff_only = staff_only
         self.staff_only = staff_only
+        if link:
+            self._url = reverse_lazy(link)
         if permissions is not None:
         if permissions is not None:
             if type(permissions) not in (list, tuple):
             if type(permissions) not in (list, tuple):
                 raise TypeError(_("Permissions must be passed as a tuple or list."))
                 raise TypeError(_("Permissions must be passed as a tuple or list."))
@@ -52,6 +59,14 @@ class PluginMenuItem:
                 raise TypeError(_("Buttons must be passed as a tuple or list."))
                 raise TypeError(_("Buttons must be passed as a tuple or list."))
             self.buttons = buttons
             self.buttons = buttons
 
 
+    @property
+    def url(self):
+        return self._url
+
+    @url.setter
+    def url(self, value):
+        self._url = value
+
 
 
 class PluginMenuButton:
 class PluginMenuButton:
     """
     """
@@ -60,11 +75,14 @@ class PluginMenuButton:
     """
     """
     color = ButtonColorChoices.DEFAULT
     color = ButtonColorChoices.DEFAULT
     permissions = []
     permissions = []
+    _url = None
 
 
     def __init__(self, link, title, icon_class, color=None, permissions=None):
     def __init__(self, link, title, icon_class, color=None, permissions=None):
         self.link = link
         self.link = link
         self.title = title
         self.title = title
         self.icon_class = icon_class
         self.icon_class = icon_class
+        if link:
+            self._url = reverse_lazy(link)
         if permissions is not None:
         if permissions is not None:
             if type(permissions) not in (list, tuple):
             if type(permissions) not in (list, tuple):
                 raise TypeError(_("Permissions must be passed as a tuple or list."))
                 raise TypeError(_("Permissions must be passed as a tuple or list."))
@@ -73,3 +91,11 @@ class PluginMenuButton:
             if color not in ButtonColorChoices.values():
             if color not in ButtonColorChoices.values():
                 raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
                 raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
             self.color = color
             self.color = color
+
+    @property
+    def url(self):
+        return self._url
+
+    @url.setter
+    def url(self, value):
+        self._url = value

+ 5 - 3
netbox/netbox/search/backends.py

@@ -115,11 +115,13 @@ class CachedValueSearchBackend(SearchBackend):
         if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
         if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
             # "Starts/ends with" matches are valid only on string values
             # "Starts/ends with" matches are valid only on string values
             query_filter &= Q(type=FieldTypes.STRING)
             query_filter &= Q(type=FieldTypes.STRING)
-        elif lookup == LookupTypes.PARTIAL:
+        elif lookup in (LookupTypes.PARTIAL, LookupTypes.EXACT):
             try:
             try:
-                # If the value looks like an IP address, add an extra match for CIDR values
+                # If the value looks like an IP address, add extra filters for CIDR/INET values
                 address = str(netaddr.IPNetwork(value.strip()).cidr)
                 address = str(netaddr.IPNetwork(value.strip()).cidr)
-                query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
+                query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address)
+                if lookup == LookupTypes.PARTIAL:
+                    query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
             except (AddrFormatError, ValueError):
             except (AddrFormatError, ValueError):
                 pass
                 pass
 
 

+ 11 - 0
netbox/netbox/tests/test_jobs.py

@@ -7,10 +7,15 @@ from django_rq import get_queue
 from ..jobs import *
 from ..jobs import *
 from core.models import DataSource, Job
 from core.models import DataSource, Job
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
+from core.exceptions import JobFailed
+from utilities.testing import disable_warnings
 
 
 
 
 class TestJobRunner(JobRunner):
 class TestJobRunner(JobRunner):
+
     def run(self, *args, **kwargs):
     def run(self, *args, **kwargs):
+        if kwargs.get('make_fail', False):
+            raise JobFailed()
         self.logger.debug("Debug message")
         self.logger.debug("Debug message")
         self.logger.info("Info message")
         self.logger.info("Info message")
         self.logger.warning("Warning message")
         self.logger.warning("Warning message")
@@ -60,6 +65,12 @@ class JobRunnerTest(JobRunnerTestCase):
         self.assertEqual(job.log_entries[2]['message'], "Warning message")
         self.assertEqual(job.log_entries[2]['message'], "Warning message")
         self.assertEqual(job.log_entries[3]['message'], "Error message")
         self.assertEqual(job.log_entries[3]['message'], "Error message")
 
 
+    def test_handle_failed(self):
+        with disable_warnings('netbox.jobs'):
+            job = TestJobRunner.enqueue(immediate=True, make_fail=True)
+
+        self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED)
+
     def test_handle_errored(self):
     def test_handle_errored(self):
         class ErroredJobRunner(TestJobRunner):
         class ErroredJobRunner(TestJobRunner):
             EXP = Exception('Test error')
             EXP = Exception('Test error')

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 9 - 9
netbox/project-static/package.json

@@ -23,13 +23,13 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@mdi/font": "7.4.47",
     "@mdi/font": "7.4.47",
-    "@tabler/core": "1.3.2",
+    "@tabler/core": "1.4.0",
     "bootstrap": "5.3.7",
     "bootstrap": "5.3.7",
     "clipboard": "2.0.11",
     "clipboard": "2.0.11",
     "flatpickr": "4.6.13",
     "flatpickr": "4.6.13",
-    "gridstack": "12.2.1",
-    "htmx.org": "2.0.5",
-    "query-string": "9.2.1",
+    "gridstack": "12.2.2",
+    "htmx.org": "2.0.6",
+    "query-string": "9.2.2",
     "sass": "1.89.2",
     "sass": "1.89.2",
     "tom-select": "2.4.3",
     "tom-select": "2.4.3",
     "typeface-inter": "3.18.1",
     "typeface-inter": "3.18.1",
@@ -39,15 +39,15 @@
     "@types/bootstrap": "5.2.10",
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^0.6.0",
     "@types/cookie": "^0.6.0",
     "@types/node": "^22.3.0",
     "@types/node": "^22.3.0",
-    "@typescript-eslint/eslint-plugin": "^8.1.0",
-    "@typescript-eslint/parser": "^8.1.0",
-    "esbuild": "^0.25.3",
+    "@typescript-eslint/eslint-plugin": "^8.37.0",
+    "@typescript-eslint/parser": "^8.37.0",
+    "esbuild": "^0.25.6",
     "esbuild-sass-plugin": "^3.3.1",
     "esbuild-sass-plugin": "^3.3.1",
     "eslint": "<9.0",
     "eslint": "<9.0",
     "eslint-config-prettier": "^9.1.0",
     "eslint-config-prettier": "^9.1.0",
     "eslint-import-resolver-typescript": "^3.6.3",
     "eslint-import-resolver-typescript": "^3.6.3",
-    "eslint-plugin-import": "^2.30.0",
-    "eslint-plugin-prettier": "^5.2.1",
+    "eslint-plugin-import": "^2.32.0",
+    "eslint-plugin-prettier": "^5.5.1",
     "prettier": "^3.3.3",
     "prettier": "^3.3.3",
     "typescript": "<5.5"
     "typescript": "<5.5"
   },
   },

+ 4 - 0
netbox/project-static/styles/custom/racks.scss

@@ -0,0 +1,4 @@
+.rack-loading-container {
+  min-height: 200px;
+  margin-left: 30px;
+}

+ 1 - 0
netbox/project-static/styles/netbox.scss

@@ -27,3 +27,4 @@
 @import 'custom/markdown';
 @import 'custom/markdown';
 @import 'custom/misc';
 @import 'custom/misc';
 @import 'custom/notifications';
 @import 'custom/notifications';
+@import 'custom/racks';

Разлика између датотеке није приказан због своје велике величине
+ 501 - 291
netbox/project-static/yarn.lock


+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.3.3"
+version: "4.3.4"
 edition: "Community"
 edition: "Community"
-published: "2025-06-26"
+published: "2025-07-15"

+ 12 - 1
netbox/templates/dcim/inc/rack_elevation.html

@@ -1,6 +1,17 @@
 {% load i18n %}
 {% load i18n %}
 <div style="margin-left: -30px">
 <div style="margin-left: -30px">
-    <object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation" aria-label="{% trans "Rack elevation" %}"></object>
+  <div
+    hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"
+    hx-trigger="intersect"
+    hx-swap="outerHTML"
+    aria-label="{% trans "Rack elevation" %}"
+  >
+    <div class="d-flex justify-content-center align-items-center rack-loading-container">
+      <div class="spinner-border" role="status">
+        <span class="visually-hidden">{% trans "Loading..." %}</span>
+      </div>
+    </div>
+  </div>
 </div>
 </div>
 <div class="text-center mt-3">
 <div class="text-center mt-3">
     <a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" hx-boost="false">
     <a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" hx-boost="false">

+ 1 - 1
netbox/templates/extras/customfieldchoiceset.html

@@ -14,7 +14,7 @@
           </tr>
           </tr>
           <tr>
           <tr>
             <th scope="row">Description</th>
             <th scope="row">Description</th>
-            <td>{{ object.description|markdown|placeholder }}</td>
+            <td>{{ object.description|placeholder }}</td>
           </tr>
           </tr>
           <tr>
           <tr>
             <th scope="row">Base Choices</th>
             <th scope="row">Base Choices</th>

+ 1 - 5
netbox/templates/inc/filter_list.html

@@ -29,11 +29,7 @@
           <div class="hr-text">
           <div class="hr-text">
             <span>{% trans "Custom Fields" %}</span>
             <span>{% trans "Custom Fields" %}</span>
           </div>
           </div>
-          {% for name in filter_form.custom_fields %}
-            {% with field=filter_form|get_item:name %}
-              {% render_field field %}
-            {% endwith %}
-          {% endfor %}
+          {% render_custom_fields filter_form %}
         </div>
         </div>
       {% endif %}
       {% endif %}
     </div>
     </div>

+ 5 - 1
netbox/tenancy/tables/contacts.py

@@ -19,6 +19,10 @@ class ContactGroupTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     contact_count = columns.LinkedCountColumn(
     contact_count = columns.LinkedCountColumn(
         viewname='tenancy:contact_list',
         viewname='tenancy:contact_list',
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
@@ -34,7 +38,7 @@ class ContactGroupTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ContactGroup
         model = ContactGroup
         fields = (
         fields = (
-            'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
+            'pk', 'name', 'parent', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
             'last_updated', 'actions',
             'last_updated', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'contact_count', 'description')
         default_columns = ('pk', 'name', 'contact_count', 'description')

+ 5 - 1
netbox/tenancy/tables/tenants.py

@@ -16,6 +16,10 @@ class TenantGroupTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     tenant_count = columns.LinkedCountColumn(
     tenant_count = columns.LinkedCountColumn(
         viewname='tenancy:tenant_list',
         viewname='tenancy:tenant_list',
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
@@ -31,7 +35,7 @@ class TenantGroupTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = TenantGroup
         model = TenantGroup
         fields = (
         fields = (
-            'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
+            'pk', 'id', 'name', 'parent', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
             'last_updated', 'actions',
             'last_updated', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'tenant_count', 'description')
         default_columns = ('pk', 'name', 'tenant_count', 'description')

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 176 - 158
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 176 - 155
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 175 - 153
netbox/translations/de/LC_MESSAGES/django.po


Разлика између датотеке није приказан због своје велике величине
+ 211 - 198
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 177 - 154
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 172 - 153
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/it/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 177 - 154
netbox/translations/it/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 179 - 158
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/nl/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 176 - 154
netbox/translations/nl/LC_MESSAGES/django.po


BIN
netbox/translations/pl/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 146 - 145
netbox/translations/pl/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 179 - 156
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 178 - 155
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 178 - 155
netbox/translations/tr/LC_MESSAGES/django.po


BIN
netbox/translations/uk/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 177 - 154
netbox/translations/uk/LC_MESSAGES/django.po


BIN
netbox/translations/zh/LC_MESSAGES/django.mo


Разлика између датотеке није приказан због своје велике величине
+ 179 - 158
netbox/translations/zh/LC_MESSAGES/django.po


+ 2 - 2
netbox/utilities/templates/navigation/menu.html

@@ -41,11 +41,11 @@
               </div>
               </div>
               {% for item, buttons in items %}
               {% for item, buttons in items %}
                 <div class="dropdown-item d-flex justify-content-between ps-3 py-0">
                 <div class="dropdown-item d-flex justify-content-between ps-3 py-0">
-                  <a href="{% url item.link %}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
+                  <a href="{{ item.url }}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
                   {% if buttons %}
                   {% if buttons %}
                     <div class="btn-group ms-1">
                     <div class="btn-group ms-1">
                       {% for button in buttons %}
                       {% for button in buttons %}
-                        <a href="{% url button.link %}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
+                        <a href="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
                           <i class="{{ button.icon_class }}"></i>
                           <i class="{{ button.icon_class }}"></i>
                         </a>
                         </a>
                       {% endfor %}
                       {% endfor %}

+ 6 - 2
netbox/wireless/tables/wirelesslan.py

@@ -18,6 +18,10 @@ class WirelessLANGroupTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     wirelesslan_count = columns.LinkedCountColumn(
     wirelesslan_count = columns.LinkedCountColumn(
         viewname='wireless:wirelesslan_list',
         viewname='wireless:wirelesslan_list',
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
@@ -33,8 +37,8 @@ class WirelessLANGroupTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = WirelessLANGroup
         model = WirelessLANGroup
         fields = (
         fields = (
-            'pk', 'name', 'wirelesslan_count', 'slug', 'description', 'comments', 'tags', 'created', 'last_updated',
-            'actions',
+            'pk', 'name', 'parent', 'slug', 'description', 'comments', 'tags', 'wirelesslan_count', 'created',
+            'last_updated', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'wirelesslan_count', 'description')
         default_columns = ('pk', 'name', 'wirelesslan_count', 'description')
 
 

+ 1 - 1
pyproject.toml

@@ -3,7 +3,7 @@
 
 
 [project]
 [project]
 name = "netbox"
 name = "netbox"
-version = "4.3.3"
+version = "4.3.4"
 requires-python = ">=3.10"
 requires-python = ">=3.10"
 authors = [
 authors = [
     { name = "NetBox Community" }
     { name = "NetBox Community" }

+ 10 - 10
requirements.txt

@@ -1,9 +1,9 @@
-Django==5.2.3
+Django==5.2.4
 django-cors-headers==4.7.0
 django-cors-headers==4.7.0
 django-debug-toolbar==5.2.0
 django-debug-toolbar==5.2.0
 django-filter==25.1
 django-filter==25.1
-django-htmx==1.23.1
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
+django-htmx==1.23.2
 django-mptt==0.17.0
 django-mptt==0.17.0
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.4.1
 django-prometheus==2.4.1
@@ -11,29 +11,29 @@ django-redis==6.0.0
 django-rich==2.0.0
 django-rich==2.0.0
 django-rq==3.0.1
 django-rq==3.0.1
 django-storages==1.14.6
 django-storages==1.14.6
-django-taggit==6.1.0
 django-tables2==2.7.5
 django-tables2==2.7.5
+django-taggit==6.1.0
 django-timezone-field==7.1
 django-timezone-field==7.1
 djangorestframework==3.16.0
 djangorestframework==3.16.0
 drf-spectacular==0.28.0
 drf-spectacular==0.28.0
-drf-spectacular-sidecar==2025.6.1
+drf-spectacular-sidecar==2025.7.1
 feedparser==6.0.11
 feedparser==6.0.11
 gunicorn==23.0.0
 gunicorn==23.0.0
 Jinja2==3.1.6
 Jinja2==3.1.6
 jsonschema==4.24.0
 jsonschema==4.24.0
 Markdown==3.8.2
 Markdown==3.8.2
-mkdocs-material==9.6.14
+mkdocs-material==9.6.15
 mkdocstrings[python]==0.29.1
 mkdocstrings[python]==0.29.1
 netaddr==1.3.0
 netaddr==1.3.0
-nh3==0.2.21
-Pillow==11.2.1
+nh3==0.2.22
+Pillow==11.3.0
 psycopg[c,pool]==3.2.9
 psycopg[c,pool]==3.2.9
 PyYAML==6.0.2
 PyYAML==6.0.2
 requests==2.32.4
 requests==2.32.4
 rq==2.4.0
 rq==2.4.0
-social-auth-app-django==5.4.3
-social-auth-core==4.6.1
-strawberry-graphql==0.275.4
+social-auth-app-django==5.5.1
+social-auth-core==4.7.0
+strawberry-graphql==0.276.0
 strawberry-graphql-django==0.60.0
 strawberry-graphql-django==0.60.0
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.8.0
 tablib==3.8.0

Неке датотеке нису приказане због велике количине промена