Compare commits

..

No commits in common. "main" and "v4.3.3" have entirely different histories.
main ... v4.3.3

79 changed files with 3538 additions and 4916 deletions

View File

@ -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.4 placeholder: v4.3.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -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.4 placeholder: v4.3.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -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/actions/workflows/ci.yml/badge.svg" alt="CI status" /></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>
<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> |

View File

@ -14,10 +14,6 @@ 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
@ -112,7 +108,6 @@ 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
@ -131,14 +126,14 @@ requests
# https://github.com/rq/rq/blob/master/CHANGES.md # https://github.com/rq/rq/blob/master/CHANGES.md
rq rq
# Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
social-auth-app-django
# Social authentication framework # Social authentication framework
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
social-auth-core social-auth-core
# Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
social-auth-app-django
# 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

View File

@ -158,7 +158,6 @@ 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.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`)
* `netbox.views.*` - Views which handle business logic for the web UI * `netbox.views.*` - Views which handle business logic for the web UI

View File

@ -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](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 at <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.

View File

@ -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).

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -15,6 +15,7 @@ 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"
@ -24,8 +25,6 @@ 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

View File

@ -1,27 +1,5 @@
# 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

View File

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

View File

@ -187,14 +187,15 @@ 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.
""" """
if status not in JobStatusChoices.TERMINAL_STATE_CHOICES: valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
if status not in valid_statuses:
raise ValueError( raise ValueError(
_("Invalid status for job termination. Choices are: {choices}").format( _("Invalid status for job termination. Choices are: {choices}").format(
choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES) choices=', '.join(valid_statuses)
) )
) )
# Set the job's status and completion time # Mark the job as completed
self.status = status self.status = status
if error: if error:
self.error = error self.error = error

View File

@ -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', 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments',
'comments', 'tags' 'tags',
] ]

View File

@ -33,7 +33,6 @@ 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__ = (
@ -441,7 +440,6 @@ 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')]]

View File

@ -19,8 +19,7 @@ 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:

View File

@ -1,44 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('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.'
),
),
]

View File

@ -1,15 +0,0 @@
{
"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"
}
}
}
}

View File

@ -398,28 +398,6 @@ 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')

View File

@ -63,10 +63,6 @@ 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'},
@ -92,8 +88,8 @@ class DeviceRoleTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.DeviceRole model = models.DeviceRole
fields = ( fields = (
'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
'description', 'slug', 'tags', 'actions', 'created', 'last_updated', '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')

View File

@ -24,10 +24,6 @@ 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'},
@ -43,7 +39,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Region model = Region
fields = ( fields = (
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', 'pk', 'id', 'name', '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')
@ -58,10 +54,6 @@ 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'},
@ -77,7 +69,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = SiteGroup model = SiteGroup
fields = ( fields = (
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', 'pk', 'id', 'name', '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')
@ -143,10 +135,6 @@ 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
@ -182,8 +170,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Location model = Location
fields = ( fields = (
'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
'vlangroup_count', 'vlangroup_count',
) )
default_columns = ( default_columns = (

View File

@ -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, tag from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from netaddr import EUI from netaddr import EUI
@ -1000,7 +1000,18 @@ inventory-items:
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): # 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
):
model = ModuleType model = ModuleType
@classmethod @classmethod
@ -1012,7 +1023,7 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
module_types = ModuleType.objects.bulk_create([ 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]),
@ -1020,8 +1031,6 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
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',
@ -1035,70 +1044,6 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'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()
@ -1859,9 +1804,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.csv_data = ( cls.csv_data = (
"name,slug,color", "name,slug,color",
"Device Role 6,device-role-6,ff0000", "Device Role 4,device-role-4,ff0000",
"Device Role 7,device-role-7,00ff00", "Device Role 5,device-role-5,00ff00",
"Device Role 8,device-role-8,0000ff", "Device Role 6,device-role-6,0000ff",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -1,14 +1,13 @@
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'
@ -20,10 +19,6 @@ 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.
@ -66,7 +61,6 @@ 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
@ -76,17 +70,16 @@ class Condition:
""" """
def _get(obj, key): def _get(obj, key):
if isinstance(obj, list): if isinstance(obj, list):
return [operator.getitem(item or {}, key) for item in obj] return [dict.get(i, key) for i in obj]
return operator.getitem(obj or {}, key)
return dict.get(obj, key)
try: try:
value = functools.reduce(_get, self.attr.split('.'), data) value = functools.reduce(_get, self.attr.split('.'), data)
except KeyError: except TypeError:
raise InvalidCondition(f"Invalid key path: {self.attr}") # Invalid key path
try: value = None
result = self.eval_func(value) 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

View File

@ -192,5 +192,5 @@ def flush_events(events):
try: try:
func = import_string(name) func = import_string(name)
func(events) func(events)
except ImportError as e: except Exception 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))

View File

@ -18,22 +18,9 @@ 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):
""" """
Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET. This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
""" """
lookup_name = 'net_contains_or_equals' lookup_name = 'net_contains_or_equals'
@ -45,5 +32,4 @@ class NetContainsOrEquals(Lookup):
CharField.register_lookup(Empty) CharField.register_lookup(Empty)
CachedValueField.register_lookup(NetHost)
CachedValueField.register_lookup(NetContainsOrEquals) CachedValueField.register_lookup(NetContainsOrEquals)

View File

@ -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, InvalidCondition from extras.conditions import ConditionSet
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,15 +142,7 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
if not self.conditions: if not self.conditions:
return True return True
logger = logging.getLogger('netbox.event_rules') return ConditionSet(self.conditions).eval(data)
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):

View File

@ -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, InvalidCondition from extras.conditions import Condition, ConditionSet
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,11 +12,16 @@ 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
@ -32,13 +37,10 @@ class ConditionTestCase(TestCase):
# dict type is unsupported # dict type is unsupported
Condition('x', 1, dict()) Condition('x', 1, dict())
def test_invalid_op_types(self): def test_invalid_op_type(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
@ -48,10 +50,7 @@ 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}}}))
with self.assertRaises(InvalidCondition): self.assertFalse(c.eval({'a': {'b': {'c': 1}}}))
c.eval({'x': {'y': None}})
with self.assertRaises(InvalidCondition):
c.eval({'x': {'y': {'a': 1}}})
# #
# Operator tests # Operator tests
@ -75,31 +74,23 @@ 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')
@ -115,8 +106,6 @@ 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)

View File

@ -162,11 +162,6 @@ 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
@ -335,11 +330,6 @@ 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
@ -818,11 +808,6 @@ 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,

View File

@ -8,7 +8,6 @@ from django_pglocks import advisory_lock
from rq.timeouts import JobTimeoutException from rq.timeouts import JobTimeoutException
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.exceptions import JobFailed
from core.models import Job, ObjectType from core.models import Job, ObjectType
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from netbox.registry import registry from netbox.registry import registry
@ -74,21 +73,15 @@ 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:
logger.error(e) logging.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:

View File

@ -1,8 +1,6 @@
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',
@ -24,46 +22,20 @@ 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:

View File

@ -1,4 +1,3 @@
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 _
@ -33,23 +32,17 @@ 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 suitable for rendering via {% url item.link %}. Links are specified as Django reverse URL strings.
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__( def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None):
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."))
@ -59,14 +52,6 @@ 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:
""" """
@ -75,14 +60,11 @@ 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."))
@ -91,11 +73,3 @@ 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

View File

@ -115,13 +115,11 @@ 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 in (LookupTypes.PARTIAL, LookupTypes.EXACT): elif lookup == LookupTypes.PARTIAL:
try: try:
# If the value looks like an IP address, add extra filters for CIDR/INET values # If the value looks like an IP address, add an extra match for CIDR values
address = str(netaddr.IPNetwork(value.strip()).cidr) address = str(netaddr.IPNetwork(value.strip()).cidr)
query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address) query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=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

View File

@ -7,15 +7,11 @@ 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): pass
raise JobFailed()
class JobRunnerTestCase(TestCase): class JobRunnerTestCase(TestCase):
@ -53,12 +49,6 @@ class JobRunnerTest(JobRunnerTestCase):
self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED) self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
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')

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -23,13 +23,13 @@
}, },
"dependencies": { "dependencies": {
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"@tabler/core": "1.4.0", "@tabler/core": "1.3.2",
"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.2", "gridstack": "12.2.1",
"htmx.org": "2.0.6", "htmx.org": "2.0.5",
"query-string": "9.2.2", "query-string": "9.2.1",
"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.37.0", "@typescript-eslint/eslint-plugin": "^8.1.0",
"@typescript-eslint/parser": "^8.37.0", "@typescript-eslint/parser": "^8.1.0",
"esbuild": "^0.25.6", "esbuild": "^0.25.3",
"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.32.0", "eslint-plugin-import": "^2.30.0",
"eslint-plugin-prettier": "^5.5.1", "eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "<5.5" "typescript": "<5.5"
}, },

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,17 +1,6 @@
{% load i18n %} {% load i18n %}
<div style="margin-left: -30px"> <div style="margin-left: -30px">
<div <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>
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">

View File

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

View File

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

View File

@ -19,10 +19,6 @@ 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'},
@ -38,7 +34,7 @@ class ContactGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ContactGroup model = ContactGroup
fields = ( fields = (
'pk', 'name', 'parent', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created', 'pk', 'name', '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')

View File

@ -16,10 +16,6 @@ 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'},
@ -35,7 +31,7 @@ class TenantGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = TenantGroup model = TenantGroup
fields = ( fields = (
'pk', 'id', 'name', 'parent', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created', 'pk', 'id', 'name', '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')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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="{{ item.url }}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a> <a href="{% url item.link %}" 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="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}"> <a href="{% url button.link %}" 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 %}

View File

@ -18,10 +18,6 @@ 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'},
@ -37,8 +33,8 @@ class WirelessLANGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = WirelessLANGroup model = WirelessLANGroup
fields = ( fields = (
'pk', 'name', 'parent', 'slug', 'description', 'comments', 'tags', 'wirelesslan_count', 'created', 'pk', 'name', 'wirelesslan_count', 'slug', 'description', 'comments', 'tags', 'created', 'last_updated',
'last_updated', 'actions', 'actions',
) )
default_columns = ('pk', 'name', 'wirelesslan_count', 'description') default_columns = ('pk', 'name', 'wirelesslan_count', 'description')

View File

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

View File

@ -1,9 +1,9 @@
Django==5.2.4 Django==5.2.3
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-tables2==2.7.5
django-taggit==6.1.0 django-taggit==6.1.0
django-tables2==2.7.5
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.7.1 drf-spectacular-sidecar==2025.6.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.15 mkdocs-material==9.6.14
mkdocstrings[python]==0.29.1 mkdocstrings[python]==0.29.1
netaddr==1.3.0 netaddr==1.3.0
nh3==0.2.22 nh3==0.2.21
Pillow==11.3.0 Pillow==11.2.1
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.5.1 social-auth-app-django==5.4.3
social-auth-core==4.7.0 social-auth-core==4.6.1
strawberry-graphql==0.276.0 strawberry-graphql==0.275.4
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