Compare commits

..

26 Commits

Author SHA1 Message Date
Jeremy Stretch
43cb476223 Release v4.4.5 2025-10-28 14:34:18 -04:00
Martin Hauser
d6f756d315 feat(tables): Add ContactsColumnMixin to multiple tables
Integrate `ContactsColumnMixin` into various IPAM and VPN tables to
improve contact management. Updates table fields to include `contacts`.

Fixes #20700
2025-10-28 13:34:27 -04:00
Martin Hauser
afc62b6ffd fix(ipam): Correct VLAN ID range calculation logic
Adjust VLAN ID range calculation to use half‑open intervals for
consistency. Add a test to validate `_total_vlan_ids`.

Fixes #20610
2025-10-28 13:14:34 -04:00
bctiemann
3d4841f17f Merge pull request #20612 from pheus/20301-add-clear-all-option-to-user-notifications-dropdown
Closes #20301: Add "Dismiss all" action to notifications dropdown
2025-10-28 12:08:53 -04:00
Alexander Zimin
2aefb3af73 Add contacts field to ip addresses table view #20692 2025-10-28 08:48:36 -04:00
github-actions
4eff4d6a4a Update source translation strings 2025-10-28 05:03:24 +00:00
rinna11
9381564cab Fixes #20422: Allow Aggregate and Prefix to filter by family in GraphQL (#20626)
Co-authored-by: Rinna Izumi <rizumi@bethel.jw.org>
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
2025-10-27 09:02:28 -05:00
Jeremy Stretch
3d143d635b Closes #20675: Enable NetBox Copilot integration (#20682) 2025-10-27 08:54:38 -05:00
Martin Hauser
77307b3c91 fix(users): Disable sorting on Permission flag columns
Mark `can_view`, `can_add`, `can_change`, and `can_delete` columns in
the Permissions list as `orderable=False`. Sorting by these computed
flags persisted an invalid sort key which triggers a `FieldError` when
loading `/users/permissions/`.

Fixes #20655
2025-10-27 09:25:36 -04:00
bctiemann
aa4571b61f Merge pull request #20672 from pheus/20389-allow-all-bulk-rename
Fixes #20389: Add FilterSet support to BulkRenameView
2025-10-27 09:23:39 -04:00
Jo
56d9146323 Fixes #20499: Documented ObjectListView quick search feature for plugins (#20500) 2025-10-26 20:59:59 -05:00
github-actions
e192f64dd2 Update source translation strings 2025-10-26 05:03:34 +00:00
Martin Hauser
d433a28524 Fixes #20646: Prevent cables from connecting to marked objects (#20678) 2025-10-25 10:22:03 -05:00
Pl0xym0r
dbfdf318ad Closes #20459 : clean is_oob and is_primary on bulk_import (#20657) 2025-10-25 10:10:20 -05:00
Martin Hauser
639bc4462b Fixes #20541: Enhance filter methods with dynamic prefixing (#20579) 2025-10-24 14:58:31 -05:00
Jeremy Stretch
1c59d411f7 Apply the "netbox" label automatically for all new issues (#20666) 2025-10-24 09:27:41 -05:00
Martin Hauser
ac7a4ec4a3 feat(views): Add FilterSet support to BulkRenameView
Allow passing a FilterSet to BulkRenameView for consistent behavior with
BulkEditView and BulkDeleteView. Enables the
"Select all N matching query" functionality to expand across the full
queryset. Updates logic to handle PK lists appropriately when editing
all matched objects.

Fixes #20389
2025-10-24 14:43:35 +02:00
github-actions
0cf58e62b2 Update source translation strings 2025-10-24 05:02:27 +00:00
Jason Novinger
fb8d41b527 Fixes #20641: Handle viewsets with queryset=None in get_view_name() (#20642)
The get_view_name() utility function crashed with AttributeError when
called on viewsets that override get_queryset() without setting a
class-level queryset attribute (e.g., ObjectChangeViewSet).

This pattern became necessary in #20089 to force re-evaluation of
valid_models() on each request, ensuring ObjectChange querysets reflect
current ContentType state.

Added None check to fall back to DRF's default view naming when no
class-level queryset exists.
2025-10-23 09:39:49 -07:00
bctiemann
ae5d7911f9 Merge pull request #20665 from netbox-community/20637-improve-device-q-filter
Fixes #20637: Omit inventory item serials from device search filter to improve performance
2025-10-23 11:08:22 -04:00
Jeremy Stretch
3bd0186870 Fixes #20637: Omit inventory item serials from device search filter to improve performance 2025-10-23 10:11:08 -04:00
bctiemann
09ce8a808d Merge pull request #20651 from netbox-community/19872-script-validation-errors
Fixes #19872: Display script form validation errors
2025-10-23 09:59:29 -04:00
Martin Hauser
8eaff9dce7 feat(extras): Add "Dismiss all" action to notifications dropdown
Introduce a view to allow users to dismiss all unread notifications with
a single action. Update the notifications' template to include a
"Dismiss all" button for enhanced usability. This addition streamlines
notification management and improves the user experience.

Fixes #20301
2025-10-22 13:59:54 +02:00
github-actions
cb3308a166 Update source translation strings 2025-10-22 05:02:23 +00:00
Jason Novinger
5fbae8407e Only show non-rendered field errors in toast
When script form validation fails, display error messages for fields not
in fieldsets. Fields in fieldsets show inline errors only; hidden fields
show toast notifications to provide feedback instead of failing silently.
2025-10-21 11:54:46 -05:00
Jason Novinger
2fdd46f64c Fixes #19872: Display form validation errors for script execution
When script form validation fails (e.g., required fields excluded from
fieldsets), display error messages via Django's message framework instead
of failing silently. Error format: "field: error1, error2; field2: error".
2025-10-21 11:16:56 -05:00
99 changed files with 11943 additions and 11464 deletions

View File

@@ -2,7 +2,7 @@
name: ✨ Feature Request
type: Feature
description: Propose a new NetBox feature or enhancement
labels: ["type: feature", "status: needs triage"]
labels: ["netbox", "type: feature", "status: needs triage"]
body:
- type: markdown
attributes:
@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.4.4
placeholder: v4.4.5
validations:
required: true
- type: dropdown

View File

@@ -2,7 +2,7 @@
name: 🐛 Bug Report
type: Bug
description: Report a reproducible bug in the current release of NetBox
labels: ["type: bug", "status: needs triage"]
labels: ["netbox", "type: bug", "status: needs triage"]
body:
- type: markdown
attributes:
@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.4.4
placeholder: v4.4.5
validations:
required: true
- type: dropdown

View File

@@ -2,7 +2,7 @@
name: 📖 Documentation Change
type: Documentation
description: Suggest an addition or modification to the NetBox documentation
labels: ["type: documentation", "status: needs triage"]
labels: ["netbox", "type: documentation", "status: needs triage"]
body:
- type: dropdown
attributes:

View File

@@ -2,7 +2,7 @@
name: 🌍 Translation
type: Translation
description: Request support for a new language in the user interface
labels: ["type: translation"]
labels: ["netbox", "type: translation"]
body:
- type: markdown
attributes:

View File

@@ -2,7 +2,7 @@
name: 🏡 Housekeeping
type: Housekeeping
description: A change pertaining to the codebase itself (developers only)
labels: ["type: housekeeping"]
labels: ["netbox", "type: housekeeping"]
body:
- type: markdown
attributes:

View File

@@ -2,7 +2,7 @@
name: 🗑️ Deprecation
type: Deprecation
description: The removal of an existing feature or resource
labels: ["type: deprecation"]
labels: ["netbox", "type: deprecation"]
body:
- type: textarea
attributes:

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "NetBox REST API",
"version": "4.4.4",
"version": "4.4.5",
"license": {
"name": "Apache v2 License"
}
@@ -60645,6 +60645,14 @@
"operationId": "dcim_mac_addresses_list",
"description": "Get a list of MAC address objects.",
"parameters": [
{
"in": "query",
"name": "assigned",
"schema": {
"type": "boolean"
},
"description": "Is assigned"
},
{
"in": "query",
"name": "assigned_object_id",
@@ -61426,6 +61434,14 @@
"type": "string"
}
},
{
"in": "query",
"name": "primary",
"schema": {
"type": "boolean"
},
"description": "Is primary"
},
{
"in": "query",
"name": "q",
@@ -228988,7 +229004,6 @@
},
"key": {
"type": "string",
"writeOnly": true,
"maxLength": 40,
"minLength": 40
},
@@ -245223,6 +245238,11 @@
"format": "date-time",
"nullable": true
},
"key": {
"type": "string",
"maxLength": 40,
"minLength": 40
},
"write_enabled": {
"type": "boolean",
"description": "Permit create/update/delete operations using this key"
@@ -245369,7 +245389,6 @@
},
"key": {
"type": "string",
"writeOnly": true,
"maxLength": 40,
"minLength": 40
},

View File

@@ -53,6 +53,16 @@ Sets content for the top banner in the user interface.
---
## COPILOT_ENABLED
!!! tip "Dynamic Configuration Parameter"
Default: `True`
Enables or disables the [NetBox Copilot](https://netboxlabs.com/docs/copilot/) agent globally. When enabled, users can opt to toggle the agent individually.
---
## CENSUS_REPORTING_ENABLED
Default: `True`

View File

@@ -6,10 +6,14 @@ For enduser guidance on resetting saved table layouts, see [Features > User P
## Available Preferences
| Name | Description |
|--------------------------|---------------------------------------------------------------|
| data_format | Preferred format when rendering raw data (JSON or YAML) |
| pagination.per_page | The number of items to display per page of a paginated table |
| pagination.placement | Where to display the paginator controls relative to the table |
| tables.${table}.columns | The ordered list of columns to display when viewing the table |
| tables.${table}.ordering | A list of column names by which the table should be ordered |
| Name | Description |
|----------------------------|---------------------------------------------------------------|
| `csv_delimiter` | The delimiting character used when exporting CSV data |
| `data_format` | Preferred format when rendering raw data (JSON or YAML) |
| `locale.language` | The language selected for UI translation |
| `pagination.per_page` | The number of items to display per page of a paginated table |
| `pagination.placement` | Where to display the paginator controls relative to the table |
| `tables.${table}.columns` | The ordered list of columns to display when viewing the table |
| `tables.${table}.ordering` | A list of column names by which the table should be ordered |
| `ui.copilot_enabled` | Toggles the NetBox Copilot AI agent |
| `ui.tables.striping` | Toggles visual striping of tables in the UI |

View File

@@ -55,6 +55,27 @@ class MyModelViewSet(...):
filterset_class = filtersets.MyModelFilterSet
```
### Implementing Quick Search
The `ObjectListView` has a field called Quick Search. For Quick Search to work the corresponding FilterSet has to override the `search` method that is implemented in `NetBoxModelFilterSet`. This function takes a queryset and can perform arbitrary operations on it and return it. A common use-case is to search for the given search value in multiple fields:
```python
from django.db.models import Q
from netbox.filtersets import NetBoxModelFilterSet
class MyFilterSet(NetBoxModelFilterSet):
...
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
```
The `search` method is also used by the `q` filter in `NetBoxModelFilterSet` which in turn is used by the Search field in the filters tab.
## Filter Classes
### TagFilter

View File

@@ -1,5 +1,35 @@
# NetBox v4.4
## v4.4.5 (2025-10-28)
### Enhancements
* [#19751](https://github.com/netbox-community/netbox/issues/19751) - Disable occupied module bays in form dropdowns when installing a new module
* [#20301](https://github.com/netbox-community/netbox/issues/20301) - Add a "dismiss all" option to the notifications dropdown
* [#20399](https://github.com/netbox-community/netbox/issues/20399) - Add `assigned` and `primary` boolean filters for MAC addresses
* [#20567](https://github.com/netbox-community/netbox/issues/20567) - Add contacts column to services table
* [#20675](https://github.com/netbox-community/netbox/issues/20675) - Enable [NetBox Copilot](https://netboxlabs.com/products/netbox-copilot/) integration
* [#20692](https://github.com/netbox-community/netbox/issues/20692) - Add contacts column to IP addresses table
* [#20700](https://github.com/netbox-community/netbox/issues/20700) - Add contacts table column for various additional models
### Bug Fixes
* [#19872](https://github.com/netbox-community/netbox/issues/19872) - Ensure custom script validation failures display error messages
* [#20389](https://github.com/netbox-community/netbox/issues/20389) - Fix "select all" behavior for bulk rename views
* [#20422](https://github.com/netbox-community/netbox/issues/20422) - Enable filtering of aggregates and prefixes by family in GraphQL API
* [#20459](https://github.com/netbox-community/netbox/issues/20459) - Fix validation of `is_oob` & `is_primary` fields under IP address bulk import
* [#20466](https://github.com/netbox-community/netbox/issues/20466) - Fix querying of devices with a primary IP assigned in GraphQL API
* [#20498](https://github.com/netbox-community/netbox/issues/20498) - Enforce the validation regex (if set) for custom URL fields
* [#20524](https://github.com/netbox-community/netbox/issues/20524) - Raise a validation error when attempting to schedule a custom script for a past date/time
* [#20541](https://github.com/netbox-community/netbox/issues/20541) - Fix resolution of GraphQL object fields which rely on custom filters
* [#20551](https://github.com/netbox-community/netbox/issues/20551) - Fix automatic slug generation in quick-add UI form
* [#20606](https://github.com/netbox-community/netbox/issues/20606) - Enable copying of values from table columns rendered as badges
* [#20641](https://github.com/netbox-community/netbox/issues/20641) - Fix `AttributeError` exception raised by the object changes REST API endpoint
* [#20646](https://github.com/netbox-community/netbox/issues/20646) - Prevent cables from connecting to objects marked as connected
* [#20655](https://github.com/netbox-community/netbox/issues/20655) - Fix `FieldError` exception when attempting to sort permissions list by actions
---
## v4.4.4 (2025-10-15)
### Bug Fixes

View File

@@ -13,11 +13,8 @@ from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
from utilities.forms.mixins import FilterModifierMixin
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DatePicker, NumberWithOptions
from circuits.filtersets import CircuitFilterSet
__all__ = (
'CircuitFilterForm',
@@ -121,7 +118,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
)
class CircuitFilterForm(FilterModifierMixin, TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Circuit
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
@@ -400,7 +397,3 @@ class VirtualCircuitTerminationFilterForm(NetBoxModelFilterSetForm):
label=_('Provider')
)
tag = TagFilterField(model)
# Register FilterSet mappings for FilterModifierMixin lookup verification
FILTERSET_MAPPINGS[CircuitFilterForm] = CircuitFilterSet

View File

@@ -83,6 +83,7 @@ class ProviderBulkEditView(generic.BulkEditView):
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
class ProviderBulkRenameView(generic.BulkRenameView):
queryset = Provider.objects.all()
filterset = filtersets.ProviderFilterSet
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
@@ -150,6 +151,7 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
class ProviderAccountBulkRenameView(generic.BulkRenameView):
queryset = ProviderAccount.objects.all()
filterset = filtersets.ProviderAccountFilterSet
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
@@ -226,6 +228,7 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
queryset = ProviderNetwork.objects.all()
filterset = filtersets.ProviderNetworkFilterSet
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
@@ -290,6 +293,7 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
class CircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = CircuitType.objects.all()
filterset = filtersets.CircuitTypeFilterSet
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
@@ -362,6 +366,7 @@ class CircuitBulkEditView(generic.BulkEditView):
class CircuitBulkRenameView(generic.BulkRenameView):
queryset = Circuit.objects.all()
field_name = 'cid'
filterset = filtersets.CircuitFilterSet
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
@@ -557,6 +562,7 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
class CircuitGroupBulkRenameView(generic.BulkRenameView):
queryset = CircuitGroup.objects.all()
filterset = filtersets.CircuitGroupFilterSet
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
@@ -672,6 +678,7 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuitType.objects.all()
filterset = filtersets.VirtualCircuitTypeFilterSet
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
@@ -744,6 +751,7 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
class VirtualCircuitBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all()
field_name = 'cid'
filterset = filtersets.VirtualCircuitFilterSet
@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False)

View File

@@ -166,8 +166,8 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')),
FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')),
FieldSet(
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
name=_('Miscellaneous')
'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION',
'MAPS_URL', name=_('Miscellaneous'),
),
FieldSet('comment', name=_('Config Revision'))
)

View File

@@ -125,6 +125,7 @@ class DataSourceBulkEditView(generic.BulkEditView):
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
class DataSourceBulkRenameView(generic.BulkRenameView):
queryset = DataSource.objects.all()
filterset = filtersets.DataSourceFilterSet
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)

View File

@@ -1288,7 +1288,6 @@ class DeviceFilterSet(
Q(name__icontains=value) |
Q(virtual_chassis__name__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description__icontains=value.strip()) |
Q(comments__icontains=value) |

View File

@@ -3,7 +3,6 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.filtersets import DeviceFilterSet, PowerOutletFilterSet, RackFilterSet
from dcim.models import *
from extras.forms import LocalConfigContextFilterForm
from extras.models import ConfigTemplate
@@ -14,8 +13,6 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
from utilities.forms.mixins import FilterModifierMixin
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -320,7 +317,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
tag = TagFilterField(model)
class RackFilterForm(FilterModifierMixin, TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
@@ -741,7 +738,6 @@ class PlatformFilterForm(NetBoxModelFilterSetForm):
class DeviceFilterForm(
FilterModifierMixin,
LocalConfigContextFilterForm,
TenancyFilterForm,
ContactModelFilterForm,
@@ -1382,7 +1378,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class PowerOutletFilterForm(FilterModifierMixin, PathEndpointFilterForm, DeviceComponentFilterForm):
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
@@ -1792,9 +1788,3 @@ class InterfaceConnectionFilterForm(FilterForm):
},
label=_('Device')
)
# Register FilterSet mappings for FilterModifierMixin lookup verification
FILTERSET_MAPPINGS[DeviceFilterForm] = DeviceFilterSet
FILTERSET_MAPPINGS[RackFilterForm] = RackFilterSet
FILTERSET_MAPPINGS[PowerOutletFilterForm] = PowerOutletFilterSet

View File

@@ -393,6 +393,17 @@ class CableTermination(ChangeLoggedModel):
def clean(self):
super().clean()
# Disallow connecting a cable to any termination object that is
# explicitly flagged as "mark connected".
termination = getattr(self, 'termination', None)
if termination is not None and getattr(termination, "mark_connected", False):
raise ValidationError(
_("Cannot connect a cable to {obj_parent} > {obj} because it is marked as connected.").format(
obj_parent=termination.parent_object,
obj=termination,
)
)
# Check for existing termination
qs = CableTermination.objects.filter(
termination_type=self.termination_type,
@@ -404,14 +415,14 @@ class CableTermination(ChangeLoggedModel):
existing_termination = qs.first()
if existing_termination is not None:
raise ValidationError(
_("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
_("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}").format(
app_label=self.termination_type.app_label,
model=self.termination_type.model,
termination_id=self.termination_id,
cable_pk=existing_termination.cable.pk
))
)
)
# Validate interface type (if applicable)
# Validate the interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(
_("Cables cannot be terminated to {type_display} interfaces").format(

View File

@@ -967,6 +967,18 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_cannot_cable_to_mark_connected(self):
"""
Test that a cable cannot be connected to an interface marked as connected.
"""
device1 = Device.objects.get(name='TestDevice1')
interface1 = Interface.objects.get(device__name='TestDevice2', name='eth1')
mark_connected_interface = Interface(device=device1, name='mark_connected1', mark_connected=True)
cable = Cable(a_terminations=[mark_connected_interface], b_terminations=[interface1])
with self.assertRaises(ValidationError):
cable.clean()
class VirtualDeviceContextTestCase(TestCase):

View File

@@ -2885,6 +2885,43 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
self.client.post(self._get_url('bulk_delete'), data)
self.assertEqual(device.interfaces.count(), 4) # Child & parent were both deleted
def test_rename_select_all_spans_pages(self):
"""
Tests the bulk rename functionality for interfaces spanning multiple pages in the UI.
"""
device_name = 'DeviceRename'
device = create_test_device(device_name)
# Create > default page size (25) so selection spans multiple pages
for i in range(37):
Interface.objects.create(device=device, name=f'eth{i}')
self.add_permissions('dcim.change_interface')
# Filter to this device's interfaces to simulate a real list filter
get_qs = {'device_id': Device.objects.get(name=device_name).pk}
post_url = f'{self._get_url("bulk_rename")}?device_id={get_qs["device_id"]}'
# Preview step: ensure 37 selected (not just one page)
data = {'_preview': '1', '_all': '1', 'find': 'eth', 'replace': 'xe'}
response = self.client.post(post_url, data=data)
self.assertHttpStatus(response, 200)
self.assertEqual(len(response.context['selected_objects']), 37)
# Extract pk[] just like the browser would submit on Apply
# (either from the form's initial, or from selected_objects)
pk_list = response.context['form'].initial.get('pk')
if not pk_list:
pk_list = [obj.pk for obj in response.context['selected_objects']]
pk_list = [str(pk) for pk in pk_list]
# Apply step: include pk[] in the POST
apply_data = {'_apply': '1', '_all': '1', 'find': 'eth', 'replace': 'xe', 'pk': pk_list}
response = self.client.post(post_url, data=apply_data)
# On success the view redirects back to the return URL
self.assertHttpStatus(response, 302)
self.assertEqual(Interface.objects.filter(device=device, name__startswith='xe').count(), 37)
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort

View File

@@ -295,6 +295,7 @@ class RegionBulkEditView(generic.BulkEditView):
@register_model_view(Region, 'bulk_rename', path='rename', detail=False)
class RegionBulkRenameView(generic.BulkRenameView):
queryset = Region.objects.all()
filterset = filtersets.RegionFilterSet
@register_model_view(Region, 'bulk_delete', path='delete', detail=False)
@@ -426,6 +427,7 @@ class SiteGroupBulkEditView(generic.BulkEditView):
@register_model_view(SiteGroup, 'bulk_rename', path='rename', detail=False)
class SiteGroupBulkRenameView(generic.BulkRenameView):
queryset = SiteGroup.objects.all()
filterset = filtersets.SiteGroupFilterSet
@register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False)
@@ -516,6 +518,7 @@ class SiteBulkEditView(generic.BulkEditView):
@register_model_view(Site, 'bulk_rename', path='rename', detail=False)
class SiteBulkRenameView(generic.BulkRenameView):
queryset = Site.objects.all()
filterset = filtersets.SiteFilterSet
@register_model_view(Site, 'bulk_delete', path='delete', detail=False)
@@ -625,6 +628,7 @@ class LocationBulkEditView(generic.BulkEditView):
@register_model_view(Location, 'bulk_rename', path='rename', detail=False)
class LocationBulkRenameView(generic.BulkRenameView):
queryset = Location.objects.all()
filterset = filtersets.LocationFilterSet
@register_model_view(Location, 'bulk_delete', path='delete', detail=False)
@@ -695,6 +699,7 @@ class RackRoleBulkEditView(generic.BulkEditView):
@register_model_view(RackRole, 'bulk_rename', path='rename', detail=False)
class RackRoleBulkRenameView(generic.BulkRenameView):
queryset = RackRole.objects.all()
filterset = filtersets.RackRoleFilterSet
@register_model_view(RackRole, 'bulk_delete', path='delete', detail=False)
@@ -760,6 +765,7 @@ class RackTypeBulkEditView(generic.BulkEditView):
class RackTypeBulkRenameView(generic.BulkRenameView):
queryset = RackType.objects.all()
field_name = 'model'
filterset = filtersets.RackTypeFilterSet
@register_model_view(RackType, 'bulk_delete', path='delete', detail=False)
@@ -944,6 +950,7 @@ class RackBulkEditView(generic.BulkEditView):
@register_model_view(Rack, 'bulk_rename', path='rename', detail=False)
class RackBulkRenameView(generic.BulkRenameView):
queryset = Rack.objects.all()
filterset = filtersets.RackFilterSet
@register_model_view(Rack, 'bulk_delete', path='delete', detail=False)
@@ -1083,6 +1090,7 @@ class ManufacturerBulkEditView(generic.BulkEditView):
@register_model_view(Manufacturer, 'bulk_rename', path='rename', detail=False)
class ManufacturerBulkRenameView(generic.BulkRenameView):
queryset = Manufacturer.objects.all()
filterset = filtersets.ManufacturerFilterSet
@register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False)
@@ -1336,6 +1344,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
class DeviceTypeBulkRenameView(generic.BulkRenameView):
queryset = DeviceType.objects.all()
field_name = 'model'
filterset = filtersets.DeviceTypeFilterSet
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
@@ -1397,6 +1406,7 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
@register_model_view(ModuleTypeProfile, 'bulk_rename', path='rename', detail=False)
class ModuleTypeProfileBulkRenameView(generic.BulkRenameView):
queryset = ModuleTypeProfile.objects.all()
filterset = filtersets.ModuleTypeProfileFilterSet
@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
@@ -1612,6 +1622,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
class ModuleTypeBulkRenameView(generic.BulkRenameView):
queryset = ModuleType.objects.all()
filterset = filtersets.ModuleTypeFilterSet
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
@@ -2100,6 +2111,7 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
@register_model_view(DeviceRole, 'bulk_rename', path='rename', detail=False)
class DeviceRoleBulkRenameView(generic.BulkRenameView):
queryset = DeviceRole.objects.all()
filterset = filtersets.DeviceRoleFilterSet
@register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False)
@@ -2175,6 +2187,7 @@ class PlatformBulkEditView(generic.BulkEditView):
@register_model_view(Platform, 'bulk_rename', path='rename', detail=False)
class PlatformBulkRenameView(generic.BulkRenameView):
queryset = Platform.objects.all()
filterset = filtersets.PlatformFilterSet
@register_model_view(Platform, 'bulk_delete', path='delete', detail=False)
@@ -2582,6 +2595,7 @@ class ConsolePortBulkEditView(generic.BulkEditView):
@register_model_view(ConsolePort, 'bulk_rename', path='rename', detail=False)
class ConsolePortBulkRenameView(generic.BulkRenameView):
queryset = ConsolePort.objects.all()
filterset = filtersets.ConsolePortFilterSet
@register_model_view(ConsolePort, 'bulk_disconnect', path='disconnect', detail=False)
@@ -2652,6 +2666,7 @@ class ConsoleServerPortBulkEditView(generic.BulkEditView):
@register_model_view(ConsoleServerPort, 'bulk_rename', path='rename', detail=False)
class ConsoleServerPortBulkRenameView(generic.BulkRenameView):
queryset = ConsoleServerPort.objects.all()
filterset = filtersets.ConsoleServerPortFilterSet
@register_model_view(ConsoleServerPort, 'bulk_disconnect', path='disconnect', detail=False)
@@ -2722,6 +2737,7 @@ class PowerPortBulkEditView(generic.BulkEditView):
@register_model_view(PowerPort, 'bulk_rename', path='rename', detail=False)
class PowerPortBulkRenameView(generic.BulkRenameView):
queryset = PowerPort.objects.all()
filterset = filtersets.PowerPortFilterSet
@register_model_view(PowerPort, 'bulk_disconnect', path='disconnect', detail=False)
@@ -2792,6 +2808,7 @@ class PowerOutletBulkEditView(generic.BulkEditView):
@register_model_view(PowerOutlet, 'bulk_rename', path='rename', detail=False)
class PowerOutletBulkRenameView(generic.BulkRenameView):
queryset = PowerOutlet.objects.all()
filterset = filtersets.PowerOutletFilterSet
@register_model_view(PowerOutlet, 'bulk_disconnect', path='disconnect', detail=False)
@@ -2934,6 +2951,7 @@ class InterfaceBulkEditView(generic.BulkEditView):
@register_model_view(Interface, 'bulk_rename', path='rename', detail=False)
class InterfaceBulkRenameView(generic.BulkRenameView):
queryset = Interface.objects.all()
filterset = filtersets.InterfaceFilterSet
@register_model_view(Interface, 'bulk_disconnect', path='disconnect', detail=False)
@@ -3005,6 +3023,7 @@ class FrontPortBulkEditView(generic.BulkEditView):
@register_model_view(FrontPort, 'bulk_rename', path='rename', detail=False)
class FrontPortBulkRenameView(generic.BulkRenameView):
queryset = FrontPort.objects.all()
filterset = filtersets.FrontPortFilterSet
@register_model_view(FrontPort, 'bulk_disconnect', path='disconnect', detail=False)
@@ -3080,6 +3099,7 @@ class RearPortBulkRenameView(generic.BulkRenameView):
@register_model_view(RearPort, 'bulk_disconnect', path='disconnect', detail=False)
class RearPortBulkDisconnectView(BulkDisconnectView):
queryset = RearPort.objects.all()
filterset = filtersets.RearPortFilterSet
@register_model_view(RearPort, 'bulk_delete', path='delete', detail=False)
@@ -3145,6 +3165,7 @@ class ModuleBayBulkEditView(generic.BulkEditView):
@register_model_view(ModuleBay, 'bulk_rename', path='rename', detail=False)
class ModuleBayBulkRenameView(generic.BulkRenameView):
queryset = ModuleBay.objects.all()
filterset = filtersets.ModuleBayFilterSet
@register_model_view(ModuleBay, 'bulk_delete', path='delete', detail=False)
@@ -3287,6 +3308,7 @@ class DeviceBayBulkEditView(generic.BulkEditView):
@register_model_view(DeviceBay, 'bulk_rename', path='rename', detail=False)
class DeviceBayBulkRenameView(generic.BulkRenameView):
queryset = DeviceBay.objects.all()
filterset = filtersets.DeviceBayFilterSet
@register_model_view(DeviceBay, 'bulk_delete', path='delete', detail=False)
@@ -3348,6 +3370,7 @@ class InventoryItemBulkEditView(generic.BulkEditView):
@register_model_view(InventoryItem, 'bulk_rename', path='rename', detail=False)
class InventoryItemBulkRenameView(generic.BulkRenameView):
queryset = InventoryItem.objects.all()
filterset = filtersets.InventoryItemFilterSet
@register_model_view(InventoryItem, 'bulk_delete', path='delete', detail=False)
@@ -3431,6 +3454,7 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
@register_model_view(InventoryItemRole, 'bulk_rename', path='rename', detail=False)
class InventoryItemRoleBulkRenameView(generic.BulkRenameView):
queryset = InventoryItemRole.objects.all()
filterset = filtersets.InventoryItemRoleFilterSet
@register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False)
@@ -3634,6 +3658,7 @@ class CableBulkEditView(generic.BulkEditView):
class CableBulkRenameView(generic.BulkRenameView):
queryset = Cable.objects.all()
field_name = 'label'
filterset = filtersets.CableFilterSet
@register_model_view(Cable, 'bulk_delete', path='delete', detail=False)
@@ -3931,6 +3956,7 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
@register_model_view(VirtualChassis, 'bulk_rename', path='rename', detail=False)
class VirtualChassisBulkRenameView(generic.BulkRenameView):
queryset = VirtualChassis.objects.all()
filterset = filtersets.VirtualChassisFilterSet
@register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False)
@@ -3993,6 +4019,7 @@ class PowerPanelBulkEditView(generic.BulkEditView):
@register_model_view(PowerPanel, 'bulk_rename', path='rename', detail=False)
class PowerPanelBulkRenameView(generic.BulkRenameView):
queryset = PowerPanel.objects.all()
filterset = filtersets.PowerPanelFilterSet
@register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False)
@@ -4050,6 +4077,7 @@ class PowerFeedBulkEditView(generic.BulkEditView):
@register_model_view(PowerFeed, 'bulk_rename', path='rename', detail=False)
class PowerFeedBulkRenameView(generic.BulkRenameView):
queryset = PowerFeed.objects.all()
filterset = filtersets.PowerFeedFilterSet
@register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False)
@@ -4128,6 +4156,7 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
@register_model_view(VirtualDeviceContext, 'bulk_rename', path='rename', detail=False)
class VirtualDeviceContextBulkRenameView(generic.BulkRenameView):
queryset = VirtualDeviceContext.objects.all()
filterset = filtersets.VirtualDeviceContextFilterSet
@register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False)

View File

@@ -1,11 +1,14 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.test import tag
from core.choices import ManagedFileRootPathChoices
from core.events import *
from core.models import ObjectType
from dcim.models import DeviceType, Manufacturer, Site
from extras.choices import *
from extras.models import *
from extras.scripts import Script as PythonClass, IntegerVar, BooleanVar
from users.models import Group, User
from utilities.testing import ViewTestCases, TestCase
@@ -897,3 +900,70 @@ class ScriptListViewTest(TestCase):
response = self.client.get(url, {'embedded': 'true'})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')
class ScriptValidationErrorTest(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']
class TestScriptMixin:
bar = IntegerVar(min_value=0, max_value=30, default=30)
class TestScriptClass(TestScriptMixin, PythonClass):
class Meta:
name = 'Test script'
commit_default = False
fieldsets = (("Logging", ("debug_mode",)),)
debug_mode = BooleanVar(default=False)
def run(self, data, commit):
return "Complete"
@classmethod
def setUpTestData(cls):
module = ScriptModule.objects.create(file_root=ManagedFileRootPathChoices.SCRIPTS, file_path='test_script.py')
cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
def setUp(self):
super().setUp()
Script.python_class = property(lambda self: ScriptValidationErrorTest.TestScriptClass)
@tag('regression')
def test_script_validation_error_displays_message(self):
from unittest.mock import patch
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
response = self.client.post(url, {'debug_mode': 'true', '_commit': 'true'})
self.assertEqual(response.status_code, 200)
messages = list(response.context['messages'])
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "bar: This field is required.")
@tag('regression')
def test_script_validation_error_no_toast_for_fieldset_fields(self):
from unittest.mock import patch, PropertyMock
class FieldsetScript(PythonClass):
class Meta:
name = 'Fieldset test'
commit_default = False
fieldsets = (("Fields", ("required_field",)),)
required_field = IntegerVar(min_value=10)
def run(self, data, commit):
return "Complete"
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
mock_python_class.return_value = FieldsetScript
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
response = self.client.post(url, {'required_field': '5', '_commit': 'true'})
self.assertEqual(response.status_code, 200)
messages = list(response.context['messages'])
self.assertEqual(len(messages), 0)

View File

@@ -4,7 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage
from django.db.models import Count, Q
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse, Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
@@ -25,7 +25,7 @@ from netbox.object_actions import *
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import htmx_partial
from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.query import count_related
from utilities.querydict import normalize_querydict
@@ -101,6 +101,7 @@ class CustomFieldBulkEditView(generic.BulkEditView):
@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
class CustomFieldBulkRenameView(generic.BulkRenameView):
queryset = CustomField.objects.all()
filterset = filtersets.CustomFieldFilterSet
@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
@@ -175,6 +176,7 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
@@ -230,6 +232,7 @@ class CustomLinkBulkEditView(generic.BulkEditView):
@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
class CustomLinkBulkRenameView(generic.BulkRenameView):
queryset = CustomLink.objects.all()
filterset = filtersets.CustomLinkFilterSet
@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
@@ -286,6 +289,7 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
class ExportTemplateBulkRenameView(generic.BulkRenameView):
queryset = ExportTemplate.objects.all()
filterset = filtersets.ExportTemplateFilterSet
@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
@@ -351,6 +355,7 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
class SavedFilterBulkRenameView(generic.BulkRenameView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
@@ -413,6 +418,7 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
class TableConfigBulkRenameView(generic.BulkRenameView):
queryset = TableConfig.objects.all()
filterset = filtersets.TableConfigFilterSet
@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
@@ -499,6 +505,7 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
class NotificationGroupBulkRenameView(generic.BulkRenameView):
queryset = NotificationGroup.objects.all()
filterset = filtersets.NotificationGroupFilterSet
@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
@@ -518,8 +525,9 @@ class NotificationsView(LoginRequiredMixin, View):
"""
def get(self, request):
return render(request, 'htmx/notifications.html', {
'notifications': request.user.notifications.unread(),
'notifications': request.user.notifications.unread()[:10],
'total_count': request.user.notifications.count(),
'unread_count': request.user.notifications.unread().count(),
})
@@ -528,6 +536,7 @@ class NotificationReadView(LoginRequiredMixin, View):
"""
Mark the Notification read and redirect the user to its attached object.
"""
def get(self, request, pk):
# Mark the Notification as read
notification = get_object_or_404(request.user.notifications, pk=pk)
@@ -541,18 +550,48 @@ class NotificationReadView(LoginRequiredMixin, View):
return redirect('account:notifications')
@register_model_view(Notification, name='dismiss_all', path='dismiss-all', detail=False)
class NotificationDismissAllView(LoginRequiredMixin, View):
"""
Convenience view to clear all *unread* notifications for the current user.
"""
def get(self, request):
request.user.notifications.unread().delete()
if htmx_partial(request):
# If a user is currently on the notification page, redirect there (full repaint)
redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True)
if redirect_resp:
return redirect_resp
return render(request, 'htmx/notifications.html', {
'notifications': request.user.notifications.unread()[:10],
'total_count': request.user.notifications.count(),
'unread_count': request.user.notifications.unread().count(),
})
return redirect('account:notifications')
@register_model_view(Notification, 'dismiss')
class NotificationDismissView(LoginRequiredMixin, View):
"""
A convenience view which allows deleting notifications with one click.
"""
def get(self, request, pk):
notification = get_object_or_404(request.user.notifications, pk=pk)
notification.delete()
if htmx_partial(request):
# If a user is currently on the notification page, redirect there (full repaint)
redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True)
if redirect_resp:
return redirect_resp
return render(request, 'htmx/notifications.html', {
'notifications': request.user.notifications.unread()[:10],
'total_count': request.user.notifications.count(),
'unread_count': request.user.notifications.unread().count(),
})
return redirect('account:notifications')
@@ -650,6 +689,7 @@ class WebhookBulkEditView(generic.BulkEditView):
@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
class WebhookBulkRenameView(generic.BulkRenameView):
queryset = Webhook.objects.all()
filterset = filtersets.WebhookFilterSet
@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
@@ -705,6 +745,7 @@ class EventRuleBulkEditView(generic.BulkEditView):
@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
class EventRuleBulkRenameView(generic.BulkRenameView):
queryset = EventRule.objects.all()
filterset = filtersets.EventRuleFilterSet
@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
@@ -841,6 +882,7 @@ class ConfigContextProfileBulkEditView(generic.BulkEditView):
@register_model_view(ConfigContextProfile, 'bulk_rename', path='rename', detail=False)
class ConfigContextProfileBulkRenameView(generic.BulkRenameView):
queryset = ConfigContextProfile.objects.all()
filterset = filtersets.ConfigContextProfileFilterSet
@register_model_view(ConfigContextProfile, 'bulk_delete', path='delete', detail=False)
@@ -929,6 +971,7 @@ class ConfigContextBulkEditView(generic.BulkEditView):
@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
class ConfigContextBulkRenameView(generic.BulkRenameView):
queryset = ConfigContext.objects.all()
filterset = filtersets.ConfigContextFilterSet
@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
@@ -1020,6 +1063,7 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
class ConfigTemplateBulkRenameView(generic.BulkRenameView):
queryset = ConfigTemplate.objects.all()
filterset = filtersets.ConfigTemplateFilterSet
@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
@@ -1143,6 +1187,7 @@ class ImageAttachmentBulkEditView(generic.BulkEditView):
@register_model_view(ImageAttachment, 'bulk_rename', path='rename', detail=False)
class ImageAttachmentBulkRenameView(generic.BulkRenameView):
queryset = ImageAttachment.objects.all()
filterset = filtersets.ImageAttachmentFilterSet
@register_model_view(ImageAttachment, 'bulk_delete', path='delete', detail=False)
@@ -1485,6 +1530,15 @@ class ScriptView(BaseScriptView):
)
return redirect('extras:script_result', job_pk=job.pk)
else:
fieldset_fields = {field for _, fields in script_class.get_fieldsets() for field in fields}
hidden_errors = {
field: errors for field, errors in form.errors.items()
if field not in fieldset_fields
}
if hidden_errors:
error_msg = '; '.join(f"{field}: {', '.join(errors)}" for field, errors in hidden_errors.items())
messages.error(request, error_msg)
return render(request, 'extras/script.html', {
'object': script,

View File

@@ -369,6 +369,20 @@ class IPAddressImportForm(NetBoxModelImportForm):
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
)
def clean_is_primary(self):
# Make sure is_primary is None when it's not included in the uploaded data
if 'is_primary' not in self.data:
return None
else:
return self.cleaned_data['is_primary']
def clean_is_oob(self):
# Make sure is_oob is None when it's not included in the uploaded data
if 'is_oob' not in self.data:
return None
else:
return self.cleaned_data['is_oob']
def clean(self):
super().clean()
@@ -411,18 +425,18 @@ class IPAddressImportForm(NetBoxModelImportForm):
ipaddress = super().save(*args, **kwargs)
# Set as primary for device/VM
if self.cleaned_data.get('is_primary'):
if self.cleaned_data.get('is_primary') is not None:
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress
parent.primary_ip4 = ipaddress if self.cleaned_data.get('is_primary') else None
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress
parent.primary_ip6 = ipaddress if self.cleaned_data.get('is_primary') else None
parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob'):
if self.cleaned_data.get('is_oob') is not None:
parent = self.cleaned_data.get('device')
parent.oob_ip = ipaddress
parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
parent.save()
return ipaddress

View File

@@ -79,12 +79,36 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
@strawberry_django.filter_type(models.Aggregate, lookups=True)
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
prefix_id: ID | None = strawberry_django.filter_field()
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
@strawberry_django.filter_field()
def contains(self, value: list[str], prefix) -> Q:
"""
Return aggregates whose `prefix` contains any of the supplied networks.
Mirrors PrefixFilter.contains but operates on the Aggregate.prefix field itself.
"""
if not value:
return Q()
q = Q()
for subnet in value:
try:
query = str(netaddr.IPNetwork(subnet.strip()).cidr)
except (AddrFormatError, ValueError):
continue
q |= Q(**{f"{prefix}prefix__net_contains": query})
return q
@strawberry_django.filter_field()
def family(
self,
value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')],
prefix,
) -> Q:
return Q(**{f"{prefix}prefix__family": value.value})
@strawberry_django.filter_type(models.FHRPGroup, lookups=True)
class FHRPGroupFilter(PrimaryModelFilterMixin):
@@ -119,28 +143,28 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin)
)
@strawberry_django.filter_field()
def device_id(self, queryset, value: list[str], prefix) -> Q:
return self.filter_device('id', value)
def device_id(self, value: list[str], prefix) -> Q:
return self.filter_device('id', value, prefix)
@strawberry_django.filter_field()
def device(self, value: list[str], prefix) -> Q:
return self.filter_device('name', value)
return self.filter_device('name', value, prefix)
@strawberry_django.filter_field()
def virtual_machine_id(self, value: list[str], prefix) -> Q:
return Q(interface_id__in=VMInterface.objects.filter(virtual_machine_id__in=value))
return Q(**{f"{prefix}interface_id__in": VMInterface.objects.filter(virtual_machine_id__in=value)})
@strawberry_django.filter_field()
def virtual_machine(self, value: list[str], prefix) -> Q:
return Q(interface_id__in=VMInterface.objects.filter(virtual_machine__name__in=value))
return Q(**{f"{prefix}interface_id__in": VMInterface.objects.filter(virtual_machine__name__in=value)})
def filter_device(self, field, value) -> Q:
def filter_device(self, field, value, prefix) -> Q:
"""Helper to standardize logic for device and device_id filters"""
devices = Device.objects.filter(**{f'{field}__in': value})
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
return Q(interface_id__in=interface_ids)
return Q(**{f"{prefix}interface_id__in": interface_ids})
@strawberry_django.filter_type(models.IPAddress, lookups=True)
@@ -180,9 +204,9 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
for subnet in value:
try:
query = str(netaddr.IPNetwork(subnet.strip()).cidr)
q |= Q(address__net_host_contained=query)
except (AddrFormatError, ValueError):
return Q()
continue
q |= Q(**{f"{prefix}address__net_host_contained": query})
return q
@strawberry_django.filter_field()
@@ -217,9 +241,14 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
for subnet in value:
try:
query = str(netaddr.IPNetwork(subnet.strip()).cidr)
q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query)
except (AddrFormatError, ValueError):
return Q()
continue
q |= Q(
**{
f"{prefix}start_address__net_host_contained": query,
f"{prefix}end_address__net_host_contained": query,
}
)
return q
@strawberry_django.filter_field()
@@ -228,10 +257,17 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
return Q()
q = Q()
for subnet in value:
net = netaddr.IPNetwork(subnet.strip())
try:
net = netaddr.IPNetwork(subnet.strip())
query_start = str(netaddr.IPAddress(net.first))
query_end = str(netaddr.IPAddress(net.last))
except (AddrFormatError, ValueError):
continue
q |= Q(
start_address__host__inet__lte=str(netaddr.IPAddress(net.first)),
end_address__host__inet__gte=str(netaddr.IPAddress(net.last)),
**{
f"{prefix}start_address__host__inet__lte": query_start,
f"{prefix}end_address__host__inet__gte": query_end,
}
)
return q
@@ -257,10 +293,21 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
return Q()
q = Q()
for subnet in value:
query = str(netaddr.IPNetwork(subnet.strip()).cidr)
q |= Q(prefix__net_contains=query)
try:
query = str(netaddr.IPNetwork(subnet.strip()).cidr)
except (AddrFormatError, ValueError):
continue
q |= Q(**{f"{prefix}prefix__net_contains": query})
return q
@strawberry_django.filter_field()
def family(
self,
value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')],
prefix,
) -> Q:
return Q(**{f"{prefix}prefix__family": value.value})
@strawberry_django.filter_type(models.RIR, lookups=True)
class RIRFilter(OrganizationalModelFilterMixin):

View File

@@ -0,0 +1,27 @@
from django.db import migrations
def populate_vlangroup_total_vlan_ids(apps, schema_editor):
VLANGroup = apps.get_model('ipam', 'VLANGroup')
db_alias = schema_editor.connection.alias
vlan_groups = VLANGroup.objects.using(db_alias).only('id', 'vid_ranges')
for group in vlan_groups:
total_vlan_ids = 0
if group.vid_ranges:
for r in group.vid_ranges:
# Half-open [lo, hi): length is (hi - lo).
if r is not None and r.lower is not None and r.upper is not None:
total_vlan_ids += r.upper - r.lower
group._total_vlan_ids = total_vlan_ids
VLANGroup.objects.using(db_alias).bulk_update(vlan_groups, ['_total_vlan_ids'], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('ipam', '0082_add_prefix_network_containment_indexes'),
]
operations = [
migrations.RunPython(populate_vlangroup_total_vlan_ids, migrations.RunPython.noop),
]

View File

@@ -132,7 +132,8 @@ class VLANGroup(OrganizationalModel):
def save(self, *args, **kwargs):
self._total_vlan_ids = 0
for vid_range in self.vid_ranges:
self._total_vlan_ids += vid_range.upper - vid_range.lower + 1
# VID range is inclusive on lower-bound, exclusive on upper-bound
self._total_vlan_ids += vid_range.upper - vid_range.lower
super().save(*args, **kwargs)

View File

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from ipam.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
__all__ = (
'ASNTable',
@@ -36,7 +36,7 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
default_columns = ('pk', 'name', 'rir', 'start', 'end', 'tenant', 'asn_count', 'description')
class ASNTable(TenancyColumnsMixin, NetBoxTable):
class ASNTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
asn = tables.Column(
verbose_name=_('ASN'),
linkify=True
@@ -76,7 +76,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
model = ASN
fields = (
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description',
'comments', 'sites', 'tags', 'created', 'last_updated', 'actions',
'contacts', 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = (
'pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant',

View File

@@ -1,11 +1,11 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from ipam.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin, TenantColumn
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin, TenantColumn
from .template_code import *
__all__ = (
@@ -58,7 +58,7 @@ class RIRTable(NetBoxTable):
# Aggregates
#
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
class AggregateTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
prefix = tables.Column(
linkify=True,
verbose_name=_('Aggregate'),
@@ -93,7 +93,7 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
model = Aggregate
fields = (
'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added',
'description', 'comments', 'tags', 'created', 'last_updated',
'description', 'contacts', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@@ -154,7 +154,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
"""
class PrefixTable(TenancyColumnsMixin, NetBoxTable):
class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
prefix = columns.TemplateColumn(
verbose_name=_('Prefix'),
template_code=PREFIX_LINK_WITH_DEPTH,
@@ -237,8 +237,8 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
model = Prefix
fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group',
'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments',
'tags', 'created', 'last_updated',
'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'contacts',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role',
@@ -252,7 +252,7 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
#
# IP ranges
#
class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
class IPRangeTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
start_address = tables.Column(
verbose_name=_('Start address'),
linkify=True
@@ -293,8 +293,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
model = IPRange
fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group',
'mark_populated', 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created',
'last_updated',
'mark_populated', 'mark_utilized', 'utilization', 'description', 'contacts', 'comments', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -308,7 +308,7 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
# IPAddresses
#
class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
class IPAddressTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
address = tables.TemplateColumn(
template_code=IPADDRESS_LINK,
verbose_name=_('IP Address')
@@ -365,7 +365,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
model = IPAddress
fields = (
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside',
'assigned', 'dns_name', 'description', 'comments', 'tags', 'created', 'last_updated',
'assigned', 'dns_name', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',

View File

@@ -323,6 +323,55 @@ class AggregateTest(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression')
def test_graphql_aggregate_prefix_exact(self):
"""
Test case to verify aggregate prefix equality via field lookup in GraphQL API.
"""
self.add_permissions('ipam.view_aggregate', 'ipam.view_rir')
rir = RIR.objects.create(name='RFC6598', slug='rfc6598', is_private=True)
aggregate1 = Aggregate.objects.create(prefix='100.64.0.0/10', rir=rir)
Aggregate.objects.create(prefix='203.0.113.0/24', rir=rir)
url = reverse('graphql')
query = """{
aggregate_list(filters: { prefix: { exact: "100.64.0.0/10" } }) { prefix }
}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
prefixes = {row['prefix'] for row in data['data']['aggregate_list']}
self.assertIn(str(aggregate1.prefix), prefixes)
@tag('regression')
def test_graphql_aggregate_contains_skips_invalid(self):
"""
Test the GraphQL API Aggregate `contains` filter skips invalid input.
"""
self.add_permissions('ipam.view_aggregate', 'ipam.view_rir')
rir = RIR.objects.create(name='RIR 3', slug='rir-3', is_private=False)
aggregate1 = Aggregate.objects.create(prefix='100.64.0.0/10', rir=rir)
Aggregate.objects.create(prefix='203.0.113.0/24', rir=rir)
url = reverse('graphql')
query = """{
aggregate_list(filters: { contains: ["100.64.16.0/24", "not-a-cidr", ""] }) { prefix }
}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
prefixes = {row['prefix'] for row in data['data']['aggregate_list']}
self.assertIn(str(aggregate1.prefix), prefixes)
# No exception occurred; invalid entries were ignored
class RoleTest(APIViewTestCases.APIViewTestCase):
model = Role
@@ -546,6 +595,30 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), 8)
@tag('regression')
def test_graphql_tenant_prefixes_contains_nested_skips_invalid(self):
"""
Test the GraphQL API Tenant nested Prefix `contains` filter skips invalid input.
"""
self.add_permissions('ipam.view_prefix', 'ipam.view_vrf', 'tenancy.view_tenant')
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
vrf = VRF.objects.create(name='Test VRF 1', rd='64512:1')
Prefix.objects.create(prefix='10.20.0.0/16', vrf=vrf, tenant=tenant)
Prefix.objects.create(prefix='198.51.100.0/24', vrf=vrf) # non-tenant
url = reverse('graphql')
query = """{
tenant_list(filters: { prefixes: { contains: ["10.20.1.0/24", "not-a-cidr"] } }) { id }
}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
self.assertTrue(data['data']['tenant_list']) # tenant returned
class IPRangeTest(APIViewTestCases.APIViewTestCase):
model = IPRange
@@ -645,6 +718,65 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), 8)
@tag('regression')
def test_graphql_tenant_ip_ranges_parent_nested_skips_invalid(self):
"""
Test the GraphQL API Tenant nested IP Range `parent` filter skips invalid input.
"""
self.add_permissions('tenancy.view_tenant', 'ipam.view_iprange', 'ipam.view_vrf')
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
vrf = VRF.objects.create(name='Test VRF 1', rd='64512:1')
IPRange.objects.create(
start_address=IPNetwork('10.30.0.1/24'), end_address=IPNetwork('10.30.0.255/24'), vrf=vrf, tenant=tenant
)
IPRange.objects.create(
start_address=IPNetwork('10.31.0.1/24'), end_address=IPNetwork('10.31.0.255/24'), vrf=vrf, tenant=tenant
)
url = reverse('graphql')
query = """{
tenant_list(filters: {
name: { exact: "Tenant 1" }
ip_ranges: { parent: ["10.30.0.0/24", "bogus"] }
}) { id }
}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
self.assertTrue(data['data']['tenant_list']) # tenant returned
# No exception occurred; invalid entries were ignored
@tag('regression')
def test_graphql_tenant_ip_ranges_contains_nested_skips_invalid(self):
"""
Test the GraphQL API Tenant nested IP Range `contains` filter skips invalid input.
"""
self.add_permissions('tenancy.view_tenant', 'ipam.view_iprange', 'ipam.view_vrf')
tenant = Tenant.objects.create(name='Tenant 2', slug='tenant-2')
vrf = VRF.objects.create(name='Test VRF 1', rd='64512:2')
IPRange.objects.create(
start_address=IPNetwork('10.40.0.1/24'), end_address=IPNetwork('10.40.0.255/24'), vrf=vrf, tenant=tenant
)
url = reverse('graphql')
query = """{
tenant_list(filters: {
name: { exact: "Tenant 2" }
ip_ranges: { contains: ["10.40.0.128/25", "###"] }
}) { id }
}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
self.assertTrue(data['data']['tenant_list']) # tenant returned
# No exception occurred; invalid entries were ignored
class IPAddressTest(APIViewTestCases.APIViewTestCase):
model = IPAddress
@@ -731,6 +863,75 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
@tag('regression')
def test_graphql_device_primary_ip4_assigned_nested(self):
"""
Test the GraphQL API Device nested IP Address `primary_ip4` filter.
"""
self.add_permissions('dcim.view_device', 'dcim.view_interface', 'ipam.view_ipaddress')
site = Site.objects.create(name='Site 1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
role = DeviceRole.objects.create(name='Switch')
device1 = Device.objects.create(name='Device 1', site=site, device_type=device_type, role=role, status='active')
interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
ip1 = IPAddress.objects.create(address='10.0.0.1/24')
ip1.assigned_object = interface1
ip1.save()
device1.primary_ip4 = ip1
device1.save()
device2 = Device.objects.create(name='Device 2', site=site, device_type=device_type, role=role, status='active')
url = reverse('graphql')
query = """{
device_list(filters: { primary_ip4: { assigned: true } }) { id name }
}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
ids = {row['id'] for row in data['data']['device_list']}
self.assertIn(str(device1.pk), ids)
self.assertNotIn(str(device2.pk), ids)
@tag('regression')
def test_graphql_device_primary_ip4_parent_nested_skips_invalid(self):
"""
Test the GraphQL API Device nested IP Address `parent` filter skips invalid input.
"""
self.add_permissions('dcim.view_device', 'dcim.view_interface', 'ipam.view_ipaddress')
site = Site.objects.create(name='Site 1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
role = DeviceRole.objects.create(name='Switch')
device1 = Device.objects.create(name='Device 1', site=site, device_type=device_type, role=role, status='active')
interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
ip1 = IPAddress.objects.create(address='192.0.2.10/24')
ip1.assigned_object = interface1
ip1.save()
device1.primary_ip4 = ip1
device1.save()
url = reverse('graphql')
query = """{
device_list(filters: { primary_ip4: { parent: ["192.0.2.0/24", "bad-cidr"] } }) { id }
}"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = response.json()
self.assertNotIn('errors', data)
ids = {row['id'] for row in data['data']['device_list']}
self.assertIn(str(device1.pk), ids)
class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroup

View File

@@ -661,6 +661,10 @@ class TestVLANGroup(TestCase):
vlangroup.full_clean()
vlangroup.save()
def test_total_vlan_ids(self):
vlangroup = VLANGroup.objects.first()
self.assertEqual(vlangroup._total_vlan_ids, 100)
class TestVLAN(TestCase):

View File

@@ -108,6 +108,7 @@ class VRFBulkEditView(generic.BulkEditView):
@register_model_view(VRF, 'bulk_rename', path='rename', detail=False)
class VRFBulkRenameView(generic.BulkRenameView):
queryset = VRF.objects.all()
filterset = filtersets.VRFFilterSet
@register_model_view(VRF, 'bulk_delete', path='delete', detail=False)
@@ -163,6 +164,7 @@ class RouteTargetBulkEditView(generic.BulkEditView):
@register_model_view(RouteTarget, 'bulk_rename', path='rename', detail=False)
class RouteTargetBulkRenameView(generic.BulkRenameView):
queryset = RouteTarget.objects.all()
filterset = filtersets.RouteTargetFilterSet
@register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False)
@@ -227,6 +229,7 @@ class RIRBulkEditView(generic.BulkEditView):
@register_model_view(RIR, 'bulk_rename', path='rename', detail=False)
class RIRBulkRenameView(generic.BulkRenameView):
queryset = RIR.objects.all()
filterset = filtersets.RIRFilterSet
@register_model_view(RIR, 'bulk_delete', path='delete', detail=False)
@@ -305,6 +308,7 @@ class ASNRangeBulkEditView(generic.BulkEditView):
@register_model_view(ASNRange, 'bulk_rename', path='rename', detail=False)
class ASNRangeBulkRenameView(generic.BulkRenameView):
queryset = ASNRange.objects.all()
filterset = filtersets.ASNRangeFilterSet
@register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False)
@@ -377,6 +381,7 @@ class ASNBulkEditView(generic.BulkEditView):
@register_model_view(ASN, 'bulk_rename', path='rename', detail=False)
class ASNBulkRenameView(generic.BulkRenameView):
queryset = ASN.objects.all()
filterset = filtersets.ASNFilterSet
@register_model_view(ASN, 'bulk_delete', path='delete', detail=False)
@@ -536,6 +541,7 @@ class RoleBulkEditView(generic.BulkEditView):
@register_model_view(Role, 'bulk_rename', path='rename', detail=False)
class RoleBulkRenameView(generic.BulkRenameView):
queryset = Role.objects.all()
filterset = filtersets.RoleFilterSet
@register_model_view(Role, 'bulk_delete', path='delete', detail=False)
@@ -820,6 +826,7 @@ class IPRangeBulkEditView(generic.BulkEditView):
@register_model_view(IPRange, 'bulk_rename', path='rename', detail=False)
class IPRangeBulkRenameView(generic.BulkRenameView):
queryset = IPRange.objects.all()
filterset = filtersets.IPRangeFilterSet
@register_model_view(IPRange, 'bulk_delete', path='delete', detail=False)
@@ -1066,6 +1073,7 @@ class VLANGroupBulkEditView(generic.BulkEditView):
@register_model_view(VLANGroup, 'bulk_rename', path='rename', detail=False)
class VLANGroupBulkRenameView(generic.BulkRenameView):
queryset = VLANGroup.objects.all()
filterset = filtersets.VLANGroupFilterSet
@register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False)
@@ -1160,6 +1168,7 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
@register_model_view(VLANTranslationPolicy, 'bulk_rename', path='rename', detail=False)
class VLANTranslationPolicyBulkRenameView(generic.BulkRenameView):
queryset = VLANTranslationPolicy.objects.all()
filterset = filtersets.VLANTranslationPolicyFilterSet
@register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False)
@@ -1315,6 +1324,7 @@ class FHRPGroupBulkEditView(generic.BulkEditView):
@register_model_view(FHRPGroup, 'bulk_rename', path='rename', detail=False)
class FHRPGroupBulkRenameView(generic.BulkRenameView):
queryset = FHRPGroup.objects.all()
filterset = filtersets.FHRPGroupFilterSet
@register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False)
@@ -1447,6 +1457,7 @@ class VLANBulkEditView(generic.BulkEditView):
@register_model_view(VLAN, 'bulk_rename', path='rename', detail=False)
class VLANBulkRenameView(generic.BulkRenameView):
queryset = VLAN.objects.all()
filterset = filtersets.VLANFilterSet
@register_model_view(VLAN, 'bulk_delete', path='delete', detail=False)
@@ -1502,6 +1513,7 @@ class ServiceTemplateBulkEditView(generic.BulkEditView):
@register_model_view(ServiceTemplate, 'bulk_rename', path='rename', detail=False)
class ServiceTemplateBulkRenameView(generic.BulkRenameView):
queryset = ServiceTemplate.objects.all()
filterset = filtersets.ServiceTemplateFilterSet
@register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False)
@@ -1574,6 +1586,7 @@ class ServiceBulkEditView(generic.BulkEditView):
@register_model_view(Service, 'bulk_rename', path='rename', detail=False)
class ServiceBulkRenameView(generic.BulkRenameView):
queryset = Service.objects.all()
filterset = filtersets.ServiceFilterSet
@register_model_view(Service, 'bulk_delete', path='delete', detail=False)

View File

@@ -183,6 +183,15 @@ PARAMS = (
description=_("Enable maintenance mode"),
field=forms.BooleanField
),
ConfigParam(
name='COPILOT_ENABLED',
label=_('NetBox Copilot enabled'),
default=True,
description=_(
"Enable the NetBox Copilot AI agent globally. If enabled, users can toggle the agent individually."
),
field=forms.BooleanField
),
ConfigParam(
name='GRAPHQL_ENABLED',
label=_('GraphQL enabled'),

View File

@@ -25,10 +25,15 @@ def preferences(request):
Adds preferences for the current user (if authenticated) to the template context.
Example: {{ preferences|get_key:"pagination.placement" }}
"""
config = get_config()
user_preferences = request.user.config if request.user.is_authenticated else {}
return {
'preferences': user_preferences,
'htmx_navigation': user_preferences.get('ui.htmx_navigation', False) == 'true'
'copilot_enabled': (
config.COPILOT_ENABLED and not django_settings.ISOLATED_DEPLOYMENT and
user_preferences.get('ui.copilot_enabled', False) == 'true'
),
'htmx_navigation': user_preferences.get('ui.htmx_navigation', False) == 'true',
}

View File

@@ -49,6 +49,15 @@ PREFERENCES = {
else ''
)
),
'ui.copilot_enabled': UserPreference(
label=_('NetBox Copilot'),
choices=(
('', _('Disabled')),
('true', _('Enabled')),
),
description=_('Enable the NetBox Copilot AI agent'),
default=False,
),
'pagination.per_page': UserPreference(
label=_('Page length'),
choices=get_page_lengths(),

View File

@@ -653,6 +653,13 @@ DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
CENSUS_URL = 'https://census.netbox.oss.netboxlabs.com/api/v1/'
#
# NetBox Copilot
#
NETBOX_COPILOT_URL = 'https://static.copilot.netboxlabs.ai/load.js'
#
# Django social auth
#

View File

@@ -799,6 +799,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
"""
field_name = 'name'
template_name = 'generic/bulk_rename.html'
# Match BulkEditView/BulkDeleteView behavior: allow passing a FilterSet
# so "Select all N matching query" can expand across the full queryset.
filterset = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -840,9 +843,16 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
def post(self, request):
logger = logging.getLogger('netbox.views.BulkRenameView')
# If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
if request.POST.get('_all') and self.filterset is not None:
pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True), request=request).qs
else:
pk_list = request.POST.getlist('pk')
selected_objects = self.queryset.filter(pk__in=pk_list)
if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
form = self.form(request.POST, initial={'pk': pk_list})
if form.is_valid():
try:
@@ -877,8 +887,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
clear_events.send(sender=self)
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
form = self.form(initial={'pk': pk_list})
return render(request, self.template_name, {
'field_name': self.field_name,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -28,7 +28,7 @@
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "12.3.3",
"htmx.org": "2.0.7",
"htmx.org": "2.0.8",
"query-string": "9.3.1",
"sass": "1.93.2",
"tom-select": "2.4.3",

View File

@@ -1,177 +0,0 @@
import { getElements } from '../util';
// Modifier codes for empty/null checking
// These map to Django's 'empty' lookup: field__empty=true/false
const MODIFIER_EMPTY_TRUE = 'empty_true';
const MODIFIER_EMPTY_FALSE = 'empty_false';
/**
* Initialize filter modifier functionality.
*
* Handles transformation of field names based on modifier selection
* at form submission time using the FormData API.
*/
export function initFilterModifiers(): void {
for (const form of getElements<HTMLFormElement>('form')) {
const modifierSelects = form.querySelectorAll<HTMLSelectElement>('.modifier-select');
if (modifierSelects.length === 0) continue;
initializeFromURL(form);
modifierSelects.forEach(select => {
select.addEventListener('change', () => handleModifierChange(select));
handleModifierChange(select);
});
// Must use submit event for GET forms
form.addEventListener('submit', e => {
e.preventDefault();
const formData = new FormData(form);
handleFormDataTransform(form, formData);
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value && String(value).trim()) {
params.append(key, String(value));
}
}
window.location.href = `${form.action}?${params.toString()}`;
});
}
}
/**
* Handle modifier dropdown changes - disable/enable value input for empty lookups.
*/
function handleModifierChange(modifierSelect: HTMLSelectElement): void {
const group = modifierSelect.closest('.filter-modifier-group');
if (!group) return;
const wrapper = group.querySelector('.filter-value-container');
if (!wrapper) return;
const valueInput = wrapper.querySelector<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>('input, select, textarea');
if (!valueInput) return;
const modifier = modifierSelect.value;
if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) {
valueInput.disabled = true;
valueInput.value = '';
const placeholder = modifierSelect.dataset.emptyPlaceholder || '(automatically set)';
valueInput.setAttribute('placeholder', placeholder);
} else {
valueInput.disabled = false;
valueInput.removeAttribute('placeholder');
}
}
/**
* Transform field names in FormData based on modifier selection.
*/
function handleFormDataTransform(form: HTMLFormElement, formData: FormData): void {
const modifierGroups = form.querySelectorAll('.filter-modifier-group');
for (const group of modifierGroups) {
const modifierSelect = group.querySelector<HTMLSelectElement>('.modifier-select');
const wrapper = group.querySelector('.filter-value-container');
if (!wrapper) continue;
const valueInput = wrapper.querySelector<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>('input, select, textarea');
if (!modifierSelect || !valueInput) continue;
const currentName = valueInput.name;
const modifier = modifierSelect.value;
if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) {
formData.delete(currentName);
const boolValue = modifier === MODIFIER_EMPTY_TRUE ? 'true' : 'false';
formData.set(`${currentName}__empty`, boolValue);
} else {
const values = formData.getAll(currentName);
if (values.length > 0 && values.some(v => String(v).trim())) {
formData.delete(currentName);
const newName = modifier === 'exact' ? currentName : `${currentName}__${modifier}`;
for (const value of values) {
if (String(value).trim()) {
formData.append(newName, value);
}
}
} else {
formData.delete(currentName);
}
}
}
}
/**
* Initialize form state from URL parameters.
* Restores modifier selection and values from query string.
*
* Process:
* 1. Parse URL parameters
* 2. For each modifier group, check which lookup variant exists in URL
* 3. Set modifier dropdown to match
* 4. Populate value field with parameter value
*/
function initializeFromURL(form: HTMLFormElement): void {
const urlParams = new URLSearchParams(window.location.search);
const modifierGroups = form.querySelectorAll('.filter-modifier-group');
for (const group of modifierGroups) {
const modifierSelect = group.querySelector<HTMLSelectElement>('.modifier-select');
const wrapper = group.querySelector('.filter-value-container');
if (!wrapper) continue;
const valueInput = wrapper.querySelector<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>('input, select, textarea');
if (!modifierSelect || !valueInput) continue;
const baseFieldName = valueInput.name;
// Special handling for empty - check if field__empty exists in URL
const emptyParam = `${baseFieldName}__empty`;
if (urlParams.has(emptyParam)) {
const emptyValue = urlParams.get(emptyParam);
const modifier = emptyValue === 'true' ? MODIFIER_EMPTY_TRUE : MODIFIER_EMPTY_FALSE;
modifierSelect.value = modifier;
continue; // Don't set value input for empty
}
for (const option of modifierSelect.options) {
const lookup = option.value;
// Skip empty_true/false as they're handled above
if (lookup === MODIFIER_EMPTY_TRUE || lookup === MODIFIER_EMPTY_FALSE) continue;
const paramName = lookup === 'exact' ? baseFieldName : `${baseFieldName}__${lookup}`;
if (urlParams.has(paramName)) {
modifierSelect.value = lookup;
if (valueInput instanceof HTMLSelectElement && valueInput.multiple) {
const values = urlParams.getAll(paramName);
for (const option of valueInput.options) {
option.selected = values.includes(option.value);
}
} else {
valueInput.value = urlParams.get(paramName) || '';
}
break;
}
}
}
}

View File

@@ -1,9 +1,8 @@
import { initFormElements } from './elements';
import { initFilterModifiers } from './filterModifiers';
import { initSpeedSelector } from './speedSelector';
export function initForms(): void {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
for (const func of [initFormElements, initSpeedSelector]) {
func();
}
}

View File

@@ -32,11 +32,3 @@ form.object-edit {
border: 1px solid $red;
}
}
// Filter modifier dropdown sizing
.modifier-select {
min-width: 10rem;
max-width: 15rem;
width: auto;
white-space: nowrap;
}

View File

@@ -2241,10 +2241,10 @@ hey-listen@^1.0.8:
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
htmx.org@2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.7.tgz#991571e009a2ea4cb60e7af8bb4c1c8c0de32ecd"
integrity sha512-YiJqF3U5KyO28VC5mPfehKJPF+n1Gni+cupK+D69TF0nm7wY6AXn3a4mPWIikfAXtl1u1F1+ZhSCS7KT8pVmqA==
htmx.org@2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.8.tgz#8ac8ba87c141b7bfda7576117476062eeb4aceda"
integrity sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==
ignore@^5.2.0:
version "5.3.2"

View File

@@ -1,3 +1,3 @@
version: "4.4.4"
version: "4.4.5"
edition: "Community"
published: "2025-10-15"
published: "2025-10-28"

View File

@@ -69,6 +69,9 @@
{% block layout %}{% endblock %}
{# Additional Javascript #}
{% if copilot_enabled and request.user.is_authenticated %}
<script src="{{ settings.NETBOX_COPILOT_URL }}" defer></script>
{% endif %}
{% block javascript %}{% endblock %}
{# User messages #}

View File

@@ -129,6 +129,10 @@
<th scope="row" class="ps-3">{% trans "Maintenance mode" %}</th>
<td>{% checkmark config.MAINTENANCE_MODE %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "NetBox Copilot enabled" %}</th>
<td>{% checkmark config.COPILOT_ENABLED %}</td>
</tr>
<tr>
<th scope="row" class="ps-3">{% trans "GraphQL enabled" %}</th>
<td>{% checkmark config.GRAPHQL_ENABLED %}</td>

View File

@@ -1,4 +1,15 @@
{% load i18n %}
<div class="card-header px-2 py-1">
<h3 class="card-title flex-fill">Notifications</h3>
{% if notifications %}
<a href="#" hx-get="{% url 'extras:notification_dismiss_all' %}" hx-target="closest .notifications"
hx-confirm="{% blocktrans trimmed count count=unread_count %}Dismiss {{ count }} unread notification?{% plural %}Dismiss {{ count }} unread notifications?{% endblocktrans %}"
class="btn btn-2 text-danger" title="{% trans 'Dismiss all unread notifications' %}">
<i class="icon mdi mdi-delete-sweep-outline"></i>
{% trans "Dismiss all" %}
</a>
{% endif %}
</div>
<div class="list-group list-group-flush list-group-hoverable" style="min-width: 300px">
{% for notification in notifications %}
<div class="list-group-item p-2">

View File

@@ -74,6 +74,7 @@ class TenantGroupBulkEditView(generic.BulkEditView):
@register_model_view(TenantGroup, 'bulk_rename', path='rename', detail=False)
class TenantGroupBulkRenameView(generic.BulkRenameView):
queryset = TenantGroup.objects.all()
filterset = filtersets.TenantGroupFilterSet
@register_model_view(TenantGroup, 'bulk_delete', path='delete', detail=False)
@@ -140,6 +141,7 @@ class TenantBulkEditView(generic.BulkEditView):
@register_model_view(Tenant, 'bulk_rename', path='rename', detail=False)
class TenantBulkRenameView(generic.BulkRenameView):
queryset = Tenant.objects.all()
filterset = filtersets.TenantFilterSet
@register_model_view(Tenant, 'bulk_delete', path='delete', detail=False)
@@ -220,6 +222,7 @@ class ContactGroupBulkEditView(generic.BulkEditView):
@register_model_view(ContactGroup, 'bulk_rename', path='rename', detail=False)
class ContactGroupBulkRenameView(generic.BulkRenameView):
queryset = ContactGroup.objects.all()
filterset = filtersets.ContactGroupFilterSet
@register_model_view(ContactGroup, 'bulk_delete', path='delete', detail=False)
@@ -286,6 +289,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
@register_model_view(ContactRole, 'bulk_rename', path='rename', detail=False)
class ContactRoleBulkRenameView(generic.BulkRenameView):
queryset = ContactRole.objects.all()
filterset = filtersets.ContactRoleFilterSet
@register_model_view(ContactRole, 'bulk_delete', path='delete', detail=False)
@@ -354,6 +358,7 @@ class ContactBulkEditView(generic.BulkEditView):
@register_model_view(Contact, 'bulk_rename', path='rename', detail=False)
class ContactBulkRenameView(generic.BulkRenameView):
queryset = Contact.objects.all()
filterset = filtersets.ContactFilterSet
@register_model_view(Contact, 'bulk_delete', path='delete', detail=False)

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

@@ -11,6 +11,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from netbox.config import get_config
from netbox.preferences import PREFERENCES
from users.constants import *
from users.models import *
@@ -64,8 +65,8 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
fieldsets = (
FieldSet(
'locale.language', 'pagination.per_page', 'pagination.placement', 'ui.htmx_navigation',
'ui.tables.striping',
'locale.language', 'ui.copilot_enabled', 'pagination.per_page', 'pagination.placement',
'ui.htmx_navigation', 'ui.tables.striping',
name=_('User Interface')
),
FieldSet('data_format', 'csv_delimiter', name=_('Miscellaneous')),
@@ -83,8 +84,7 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
def __init__(self, *args, instance=None, **kwargs):
# Get initial data from UserConfig instance
initial_data = flatten_dict(instance.data)
kwargs['initial'] = initial_data
kwargs['initial'] = flatten_dict(instance.data)
super().__init__(*args, instance=instance, **kwargs)
@@ -93,6 +93,10 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
(f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
)
# Disable Copilot preference if it has been disabled globally
if not get_config().COPILOT_ENABLED:
self.fields['ui.copilot_enabled'].disabled = True
def save(self, *args, **kwargs):
# Set UserConfig data

View File

@@ -84,15 +84,19 @@ class ObjectPermissionTable(NetBoxTable):
)
can_view = columns.BooleanColumn(
verbose_name=_('Can View'),
orderable=False,
)
can_add = columns.BooleanColumn(
verbose_name=_('Can Add'),
orderable=False,
)
can_change = columns.BooleanColumn(
verbose_name=_('Can Change'),
orderable=False,
)
can_delete = columns.BooleanColumn(
verbose_name=_('Can Delete'),
orderable=False,
)
custom_actions = columns.ArrayColumn(
verbose_name=_('Custom Actions'),

View File

@@ -117,6 +117,7 @@ class UserBulkEditView(generic.BulkEditView):
class UserBulkRenameView(generic.BulkRenameView):
queryset = User.objects.all()
field_name = 'username'
filterset = filtersets.UserFilterSet
@register_model_view(User, 'bulk_delete', path='delete', detail=False)
@@ -173,6 +174,7 @@ class GroupBulkEditView(generic.BulkEditView):
@register_model_view(Group, 'bulk_rename', path='rename', detail=False)
class GroupBulkRenameView(generic.BulkRenameView):
queryset = Group.objects.all()
filterset = filtersets.GroupFilterSet
@register_model_view(Group, 'bulk_delete', path='delete', detail=False)
@@ -211,6 +213,7 @@ class ObjectPermissionEditView(generic.ObjectEditView):
@register_model_view(ObjectPermission, 'delete')
class ObjectPermissionDeleteView(generic.ObjectDeleteView):
queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet
@register_model_view(ObjectPermission, 'bulk_edit', path='edit', detail=False)

View File

@@ -72,7 +72,7 @@ def get_view_name(view):
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name()`.
This function is provided to DRF as its VIEW_NAME_FUNCTION.
"""
if hasattr(view, 'queryset'):
if hasattr(view, 'queryset') and view.queryset is not None:
# Derive the model name from the queryset.
name = title(view.queryset.model._meta.verbose_name)
if suffix := getattr(view, 'suffix', None):

View File

@@ -1,11 +0,0 @@
# Mapping of filter form classes to their corresponding FilterSet classes
# This enables the FilterModifierMixin to verify which lookups are actually supported
# by checking the FilterSet's auto-generated lookup filters.
#
# Usage:
# from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
# from .forms.filtersets import XFilterForm
# from .filtersets import XFilterSet
# FILTERSET_MAPPINGS[XFilterForm] = XFilterSet
FILTERSET_MAPPINGS = {}

View File

@@ -5,14 +5,10 @@ from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from django.utils.translation import gettext_lazy as _
from utilities.forms.fields import ColorField, TagFilterField
from utilities.forms.widgets.modifiers import MODIFIER_EMPTY_FALSE, MODIFIER_EMPTY_TRUE
__all__ = (
'BackgroundJobMixin',
'CheckLastUpdatedMixin',
'DistanceValidationMixin',
'FilterModifierMixin',
)
@@ -79,171 +75,3 @@ class DistanceValidationMixin(forms.Form):
MaxValueValidator(Decimal(100000)),
]
)
class FilterModifierMixin:
"""
Mixin that enhances filter form fields with lookup modifier dropdowns.
Automatically detects fields that could benefit from multiple lookup options
and wraps their widgets with FilterModifierWidget.
"""
# Mapping of form field types to their supported lookups
FORM_FIELD_LOOKUPS = {
forms.CharField: [
('exact', _('Is')),
('n', _('Is Not')),
('ic', _('Contains')),
('isw', _('Starts With')),
('iew', _('Ends With')),
('ie', _('Equals (case-insensitive)')),
('regex', _('Matches Pattern')),
('iregex', _('Matches Pattern (case-insensitive)')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
forms.IntegerField: [
('exact', _('Is')),
('n', _('Is Not')),
('gt', _('Greater Than (>)')),
('gte', _('At Least (≥)')),
('lt', _('Less Than (<)')),
('lte', _('At Most (≤)')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
forms.DecimalField: [
('exact', _('Is')),
('n', _('Is Not')),
('gt', _('Greater Than (>)')),
('gte', _('At Least (≥)')),
('lt', _('Less Than (<)')),
('lte', _('At Most (≤)')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
forms.DateField: [
('exact', _('Is')),
('n', _('Is Not')),
('gt', _('After')),
('gte', _('On or After')),
('lt', _('Before')),
('lte', _('On or Before')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
forms.ModelChoiceField: [
('exact', _('Is')),
('n', _('Is Not')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
ColorField: [
('exact', _('Is')),
('n', _('Is Not')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
TagFilterField: [
('exact', _('Has These Tags')),
('n', _('Does Not Have These Tags')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
forms.MultipleChoiceField: [
('exact', _('Is')),
('n', _('Is Not')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._enhance_fields_with_modifiers()
def _enhance_fields_with_modifiers(self):
"""Wrap compatible field widgets with FilterModifierWidget."""
from utilities.forms.widgets import FilterModifierWidget
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
# Get the corresponding FilterSet if registered
filterset_class = FILTERSET_MAPPINGS.get(self.__class__)
filterset = filterset_class() if filterset_class else None
for field_name, field in self.fields.items():
if self._should_skip_field(field_name, field):
continue
lookups = self._get_lookup_choices(field, field_name)
# Verify lookups against FilterSet if available
if filterset:
lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset)
if len(lookups) > 1:
field.widget = FilterModifierWidget(
original_widget=field.widget,
lookups=lookups
)
def _should_skip_field(self, field_name, field):
"""Determine if a field should be skipped for enhancement."""
# Skip the global search field
if field_name == 'q':
return True
# Skip boolean fields (no benefit from modifiers)
if isinstance(field, (forms.BooleanField, forms.NullBooleanField)):
return True
# MultipleChoiceField and TagFilterField are now supported
# (no longer skipped)
# Skip API widget fields
if self._is_api_widget_field(field):
return True
return False
def _get_lookup_choices(self, field, field_name=None):
"""Determine the available lookup choices for a given field."""
# Walk up the MRO to find a known field type
for field_class in field.__class__.__mro__:
if field_class in self.FORM_FIELD_LOOKUPS:
return self.FORM_FIELD_LOOKUPS[field_class]
# Unknown field type - return single exact option (no enhancement)
return [('exact', _('Is'))]
def _verify_lookups_with_filterset(self, field_name, lookups, filterset):
"""Verify which lookups are actually supported by the FilterSet."""
verified_lookups = []
for lookup_code, lookup_label in lookups:
# Handle special empty_true/false codes that map to __empty
if lookup_code in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE):
filter_key = f'{field_name}__empty'
else:
filter_key = f'{field_name}__{lookup_code}' if lookup_code != 'exact' else field_name
# Check if this filter exists in the FilterSet
if filter_key in filterset.filters:
verified_lookups.append((lookup_code, lookup_label))
return verified_lookups
def _is_api_widget_field(self, field):
"""Check if a field uses an API-based widget."""
# Check field class name
if 'Dynamic' in field.__class__.__name__:
return True
# Check widget attributes for API-related data
if hasattr(field.widget, 'attrs') and field.widget.attrs:
api_attrs = ['data-url', 'data-api-url', 'data-static-params']
if any(attr in field.widget.attrs for attr in api_attrs):
return True
return False

View File

@@ -1,5 +1,4 @@
from .apiselect import *
from .datetime import *
from .misc import *
from .modifiers import *
from .select import *

View File

@@ -1,105 +0,0 @@
from django import forms
from django.utils.translation import gettext_lazy as _
__all__ = ('FilterModifierWidget',)
# Modifier codes for empty/null checking
# These map to Django's 'empty' lookup: field__empty=true/false
MODIFIER_EMPTY_TRUE = 'empty_true'
MODIFIER_EMPTY_FALSE = 'empty_false'
class FilterModifierWidget(forms.Widget):
"""
Wraps an existing widget to add a modifier dropdown for filter lookups.
The original widget's semantics (name, id, attributes) are preserved.
The modifier dropdown controls which lookup type is used (exact, contains, etc.).
"""
template_name = 'widgets/filter_modifier.html'
def __init__(self, original_widget, lookups, attrs=None):
"""
Args:
original_widget: The widget being wrapped (e.g., TextInput, NumberInput)
lookups: List of (lookup_code, label) tuples (e.g., [('exact', 'Is'), ('ic', 'Contains')])
attrs: Additional widget attributes
"""
self.original_widget = original_widget
self.lookups = lookups
super().__init__(attrs or getattr(original_widget, 'attrs', {}))
def value_from_datadict(self, data, files, name):
"""
Extract value from data, checking all possible lookup variants.
When form redisplays after validation error, the data may contain
serial__ic=test but the field is named serial. This method searches
all lookup variants to find the value.
Returns:
Just the value string for form validation. The modifier is reconstructed
during rendering from the query parameter names.
"""
# Special handling for empty - check if field__empty exists
empty_param = f"{name}__empty"
if empty_param in data:
# Return the boolean value for empty lookup
return data.get(empty_param)
# Try exact field name first
value = self.original_widget.value_from_datadict(data, files, name)
# If not found, check all modifier variants
# Note: SelectMultiple returns [] (empty list) when not found, not None
if value is None or (isinstance(value, list) and len(value) == 0):
for lookup, _ in self.lookups:
if lookup == 'exact':
continue # Already checked above
# Skip empty_true/false variants - they're handled above
if lookup in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE):
continue
lookup_name = f"{name}__{lookup}"
test_value = self.original_widget.value_from_datadict(data, files, lookup_name)
if test_value is not None:
value = test_value
break
# Return None if no value found (prevents field appearing in changed_data)
# Handle all widget empty value representations
if value is None:
return None
if isinstance(value, str) and not value.strip():
return None
if isinstance(value, (list, tuple)) and len(value) == 0:
return None
# Return just the value for form validation
return value
def get_context(self, name, value, attrs):
"""
Build context for template rendering.
Includes both the original widget's context and our modifier-specific data.
Note: value is now just a simple value (string/int/etc), not a dict.
The JavaScript initializeFromURL() will set the correct modifier dropdown
value based on URL parameters.
"""
# Get context from the original widget
original_context = self.original_widget.get_context(name, value, attrs)
# Build our wrapper context
context = super().get_context(name, value, attrs)
context['widget']['original_widget'] = original_context['widget']
context['widget']['lookups'] = self.lookups
context['widget']['field_name'] = name
# Default to 'exact' - JavaScript will update based on URL params
context['widget']['current_modifier'] = 'exact'
context['widget']['current_value'] = value or ''
# Translatable placeholder for empty lookups
context['widget']['empty_placeholder'] = _('(automatically set)')
return context

View File

@@ -1,5 +1,11 @@
from django.http import HttpResponse
from django.urls import reverse
from urllib.parse import urlsplit
__all__ = (
'htmx_current_url',
'htmx_partial',
'htmx_maybe_redirect_current_page',
)
@@ -9,3 +15,45 @@ def htmx_partial(request):
in response to an HTMX request, based on the target element.
"""
return request.htmx and not request.htmx.boosted
def htmx_current_url(request) -> str:
"""
Extracts the current URL from the HTMX-specific headers in the given request object.
This function checks for the `HX-Current-URL` header in the request's headers
and `HTTP_HX_CURRENT_URL` in the META data of the request. It preferentially
chooses the value present in the `HX-Current-URL` header and falls back to the
`HTTP_HX_CURRENT_URL` META data if the former is unavailable. If neither value
exists, it returns an empty string.
"""
return request.headers.get('HX-Current-URL') or request.META.get('HTTP_HX_CURRENT_URL', '') or ''
def htmx_maybe_redirect_current_page(
request, url_name: str, *, preserve_query: bool = True, status: int = 200
) -> HttpResponse | None:
"""
Redirects the current page in an HTMX request if conditions are met.
This function checks whether a request is an HTMX partial request and if the
current URL matches the provided target URL. If the conditions are met, it
returns an HTTP response signaling a redirect to the provided or updated target
URL. Otherwise, it returns None.
"""
if not htmx_partial(request):
return None
current = urlsplit(htmx_current_url(request))
target_path = reverse(url_name) # will raise NoReverseMatch if misconfigured
if current.path.rstrip('/') != target_path.rstrip('/'):
return None
redirect_to = target_path
if preserve_query and current.query:
redirect_to = f'{target_path}?{current.query}'
resp = HttpResponse(status=status)
resp['HX-Redirect'] = redirect_to
return resp

View File

@@ -1,16 +0,0 @@
<div class="d-flex filter-modifier-group">
{# Modifier dropdown - NO name attribute, just a UI control #}
<select class="form-select modifier-select"
data-field="{{ widget.field_name }}"
data-empty-placeholder="{{ widget.empty_placeholder }}"
aria-label="Modifier">
{% for lookup, label in widget.lookups %}
<option value="{{ lookup }}"{% if widget.current_modifier == lookup %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{# Original widget - rendered exactly as it would be without our wrapper #}
<div class="ms-2 flex-grow-1 filter-value-container">
{% include widget.original_widget.template_name with widget=widget.original_widget %}
</div>
</div>

View File

@@ -5,7 +5,6 @@ from urllib.parse import quote
from django import template
from django.urls import NoReverseMatch, reverse
from django.utils.html import conditional_escape
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from utilities.forms import get_selected_values, TableConfigForm
@@ -419,20 +418,7 @@ def applied_filters(context, model, form, query_params):
continue
querydict = query_params.copy()
# Check if this is a modifier-enhanced field
# Field may be in querydict as field__lookup instead of field
param_name = None
if filter_name in querydict:
param_name = filter_name
else:
# Check for modifier variants (field__ic, field__isw, etc.)
for key in querydict.keys():
if key.startswith(f'{filter_name}__'):
param_name = key
break
if param_name is None:
if filter_name not in querydict:
continue
# Skip saved filters, as they're displayed alongside the quick search widget
@@ -440,48 +426,14 @@ def applied_filters(context, model, form, query_params):
continue
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
querydict.pop(param_name)
# Extract modifier from parameter name (e.g., "serial__ic" → "ic")
if '__' in param_name:
modifier = param_name.split('__', 1)[1]
else:
modifier = 'exact'
# Get display value
querydict.pop(filter_name)
display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])
# Special handling for empty lookup (boolean value)
if modifier == 'empty':
if display_value.lower() in ('true', '1'):
link_text = f'{bound_field.label} {_("is empty")}'
else:
link_text = f'{bound_field.label} {_("is not empty")}'
else:
# Add friendly lookup label for other modifier-enhanced fields
lookup_labels = {
'n': _('is not'),
'ic': _('contains'),
'isw': _('starts with'),
'iew': _('ends with'),
'ie': _('equals (case-insensitive)'),
'regex': _('matches pattern'),
'iregex': _('matches pattern (case-insensitive)'),
'gt': _('>'),
'gte': _(''),
'lt': _('<'),
'lte': _(''),
}
if modifier != 'exact' and modifier in lookup_labels:
link_text = f'{bound_field.label} {lookup_labels[modifier]}: {display_value}'
else:
link_text = f'{bound_field.label}: {display_value}'
applied_filters.append({
'name': param_name, # Use actual param name for removal link
'value': form.cleaned_data.get(filter_name),
'name': filter_name,
'value': form.cleaned_data[filter_name],
'link_url': f'?{querydict.urlencode()}',
'link_text': link_text,
'link_text': f'{bound_field.label}: {display_value}',
})
save_link = None

View File

@@ -1,4 +1,4 @@
from django.test import Client, TestCase, override_settings
from django.test import Client, TestCase, override_settings, tag
from django.urls import reverse
from drf_spectacular.drainage import GENERATOR_STATS
from rest_framework import status
@@ -9,6 +9,7 @@ from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from ipam.models import VLAN
from netbox.config import get_config
from utilities.api import get_view_name
from utilities.testing import APITestCase, disable_warnings
@@ -267,3 +268,19 @@ class APIDocsTestCase(TestCase):
with GENERATOR_STATS.silence(): # Suppress schema generator warnings
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class GetViewNameTestCase(TestCase):
@tag('regression')
def test_get_view_name_with_none_queryset(self):
from rest_framework.viewsets import ReadOnlyModelViewSet
class MockViewSet(ReadOnlyModelViewSet):
queryset = None
view = MockViewSet()
view.suffix = 'List'
name = get_view_name(view)
self.assertEqual(name, 'Mock List')

View File

@@ -1,298 +0,0 @@
from django import forms
from django.http import QueryDict
from django.template import Context
from django.test import RequestFactory, TestCase
from dcim.forms.filtersets import DeviceFilterForm
from dcim.models import Device
from users.models import User
from utilities.forms.fields import TagFilterField
from utilities.forms.mixins import FilterModifierMixin
from utilities.forms.widgets import FilterModifierWidget
from utilities.templatetags.helpers import applied_filters
class FilterModifierWidgetTest(TestCase):
"""Tests for FilterModifierWidget value extraction and rendering."""
def test_value_from_datadict_finds_value_in_lookup_variant(self):
"""
Widget should find value from serial__ic when field is named serial.
This is critical for form redisplay after validation errors.
"""
widget = FilterModifierWidget(
original_widget=forms.TextInput(),
lookups=[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')]
)
data = QueryDict('serial__ic=test123')
result = widget.value_from_datadict(data, {}, 'serial')
self.assertEqual(result, 'test123')
def test_value_from_datadict_handles_exact_match(self):
"""Widget should detect exact match when field name has no modifier."""
widget = FilterModifierWidget(
original_widget=forms.TextInput(),
lookups=[('exact', 'Is'), ('ic', 'Contains')]
)
data = QueryDict('serial=test456')
result = widget.value_from_datadict(data, {}, 'serial')
self.assertEqual(result, 'test456')
def test_value_from_datadict_returns_none_when_no_value(self):
"""Widget should return None when no data present to avoid appearing in changed_data."""
widget = FilterModifierWidget(
original_widget=forms.TextInput(),
lookups=[('exact', 'Is'), ('ic', 'Contains')]
)
data = QueryDict('')
result = widget.value_from_datadict(data, {}, 'serial')
self.assertIsNone(result)
def test_get_context_includes_original_widget_and_lookups(self):
"""Widget context should include original widget context and lookup choices."""
widget = FilterModifierWidget(
original_widget=forms.TextInput(),
lookups=[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')]
)
value = 'test'
context = widget.get_context('serial', value, {})
self.assertIn('original_widget', context['widget'])
self.assertEqual(
context['widget']['lookups'],
[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')]
)
self.assertEqual(context['widget']['field_name'], 'serial')
self.assertEqual(context['widget']['current_modifier'], 'exact') # Defaults to exact, JS updates from URL
self.assertEqual(context['widget']['current_value'], 'test')
def test_widget_renders_modifier_dropdown_and_input(self):
"""Widget should render modifier dropdown alongside original input."""
widget = FilterModifierWidget(
original_widget=forms.TextInput(),
lookups=[('exact', 'Is'), ('ic', 'Contains')]
)
html = widget.render('serial', 'test', {})
# Should contain modifier dropdown
self.assertIn('class="form-select modifier-select"', html)
self.assertIn('data-field="serial"', html)
self.assertIn('value="exact"', html)
self.assertIn('>Is</option>', html)
self.assertIn('value="ic"', html)
self.assertIn('>Contains</option>', html)
# Should contain original input
self.assertIn('type="text"', html)
self.assertIn('name="serial"', html)
self.assertIn('value="test"', html)
class FilterModifierMixinTest(TestCase):
"""Tests for FilterModifierMixin form field enhancement."""
def test_mixin_enhances_char_field_with_modifiers(self):
"""CharField should be enhanced with contains/starts/ends modifiers."""
class TestForm(FilterModifierMixin, forms.Form):
name = forms.CharField(required=False)
form = TestForm()
self.assertIsInstance(form.fields['name'].widget, FilterModifierWidget)
self.assertGreater(len(form.fields['name'].widget.lookups), 1)
# Should have exact, ic, isw, iew
lookup_codes = [lookup[0] for lookup in form.fields['name'].widget.lookups]
self.assertIn('exact', lookup_codes)
self.assertIn('ic', lookup_codes)
def test_mixin_skips_boolean_fields(self):
"""Boolean fields should not be enhanced."""
class TestForm(FilterModifierMixin, forms.Form):
active = forms.BooleanField(required=False)
form = TestForm()
self.assertNotIsInstance(form.fields['active'].widget, FilterModifierWidget)
def test_mixin_enhances_tag_filter_field(self):
"""TagFilterField should be enhanced even though it's a MultipleChoiceField."""
class TestForm(FilterModifierMixin, forms.Form):
tag = TagFilterField(Device)
form = TestForm()
self.assertIsInstance(form.fields['tag'].widget, FilterModifierWidget)
tag_lookups = [lookup[0] for lookup in form.fields['tag'].widget.lookups]
self.assertIn('exact', tag_lookups)
self.assertIn('n', tag_lookups)
def test_mixin_enhances_multi_choice_field(self):
"""Plain MultipleChoiceField should be enhanced with choice-appropriate lookups."""
class TestForm(FilterModifierMixin, forms.Form):
status = forms.MultipleChoiceField(choices=[('a', 'A'), ('b', 'B')], required=False)
form = TestForm()
self.assertIsInstance(form.fields['status'].widget, FilterModifierWidget)
status_lookups = [lookup[0] for lookup in form.fields['status'].widget.lookups]
# Should have choice-based lookups (not text-based like contains/regex)
self.assertIn('exact', status_lookups)
self.assertIn('n', status_lookups)
self.assertIn('empty_true', status_lookups)
# Should NOT have text-based lookups
self.assertNotIn('ic', status_lookups)
self.assertNotIn('regex', status_lookups)
def test_mixin_enhances_integer_field(self):
"""IntegerField should be enhanced with comparison modifiers."""
class TestForm(FilterModifierMixin, forms.Form):
count = forms.IntegerField(required=False)
form = TestForm()
self.assertIsInstance(form.fields['count'].widget, FilterModifierWidget)
lookup_codes = [lookup[0] for lookup in form.fields['count'].widget.lookups]
self.assertIn('gte', lookup_codes)
self.assertIn('lte', lookup_codes)
def test_mixin_adds_isnull_lookup_to_all_fields(self):
"""All field types should include isnull (empty/not empty) lookup."""
class TestForm(FilterModifierMixin, forms.Form):
name = forms.CharField(required=False)
count = forms.IntegerField(required=False)
created = forms.DateField(required=False)
form = TestForm()
# CharField should have empty_true and empty_false
char_lookups = [lookup[0] for lookup in form.fields['name'].widget.lookups]
self.assertIn('empty_true', char_lookups)
self.assertIn('empty_false', char_lookups)
# IntegerField should have empty_true and empty_false
int_lookups = [lookup[0] for lookup in form.fields['count'].widget.lookups]
self.assertIn('empty_true', int_lookups)
self.assertIn('empty_false', int_lookups)
# DateField should have empty_true and empty_false
date_lookups = [lookup[0] for lookup in form.fields['created'].widget.lookups]
self.assertIn('empty_true', date_lookups)
self.assertIn('empty_false', date_lookups)
def test_char_field_includes_extended_lookups(self):
"""CharField should include negation, iexact, and regex lookups."""
class TestForm(FilterModifierMixin, forms.Form):
name = forms.CharField(required=False)
form = TestForm()
char_lookups = [lookup[0] for lookup in form.fields['name'].widget.lookups]
self.assertIn('n', char_lookups) # negation
self.assertIn('ie', char_lookups) # iexact
self.assertIn('regex', char_lookups) # regex
self.assertIn('iregex', char_lookups) # case-insensitive regex
def test_numeric_fields_include_negation(self):
"""IntegerField and DecimalField should include negation lookup."""
class TestForm(FilterModifierMixin, forms.Form):
count = forms.IntegerField(required=False)
weight = forms.DecimalField(required=False)
form = TestForm()
int_lookups = [lookup[0] for lookup in form.fields['count'].widget.lookups]
self.assertIn('n', int_lookups)
decimal_lookups = [lookup[0] for lookup in form.fields['weight'].widget.lookups]
self.assertIn('n', decimal_lookups)
def test_date_field_includes_negation(self):
"""DateField should include negation lookup."""
class TestForm(FilterModifierMixin, forms.Form):
created = forms.DateField(required=False)
form = TestForm()
date_lookups = [lookup[0] for lookup in form.fields['created'].widget.lookups]
self.assertIn('n', date_lookups)
class ExtendedLookupFilterPillsTest(TestCase):
"""Tests for filter pill rendering of extended lookups."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create(username='test_user')
def test_negation_lookup_filter_pill(self):
"""Filter pill should show 'is not' for negation lookup."""
query_params = QueryDict('serial__n=ABC123')
form = DeviceFilterForm(query_params)
request = RequestFactory().get('/', query_params)
request.user = self.user
context = Context({'request': request})
result = applied_filters(context, Device, form, query_params)
self.assertGreater(len(result['applied_filters']), 0)
filter_pill = result['applied_filters'][0]
self.assertIn('is not', filter_pill['link_text'].lower())
self.assertIn('ABC123', filter_pill['link_text'])
def test_regex_lookup_filter_pill(self):
"""Filter pill should show 'matches pattern' for regex lookup."""
query_params = QueryDict('serial__regex=^ABC.*')
form = DeviceFilterForm(query_params)
request = RequestFactory().get('/', query_params)
request.user = self.user
context = Context({'request': request})
result = applied_filters(context, Device, form, query_params)
self.assertGreater(len(result['applied_filters']), 0)
filter_pill = result['applied_filters'][0]
self.assertIn('matches pattern', filter_pill['link_text'].lower())
class EmptyLookupTest(TestCase):
"""Tests for empty (is empty/not empty) lookup support."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create(username='test_user')
def test_empty_true_appears_in_filter_pills(self):
"""Filter pill should show 'Is Empty' for empty=true."""
query_params = QueryDict('serial__empty=true')
form = DeviceFilterForm(query_params)
request = RequestFactory().get('/', query_params)
request.user = self.user
context = Context({'request': request})
result = applied_filters(context, Device, form, query_params)
self.assertGreater(len(result['applied_filters']), 0)
filter_pill = result['applied_filters'][0]
self.assertIn('empty', filter_pill['link_text'].lower())
def test_empty_false_appears_in_filter_pills(self):
"""Filter pill should show 'Is Not Empty' for empty=false."""
query_params = QueryDict('serial__empty=false')
form = DeviceFilterForm(query_params)
request = RequestFactory().get('/', query_params)
request.user = self.user
context = Context({'request': request})
result = applied_filters(context, Device, form, query_params)
self.assertGreater(len(result['applied_filters']), 0)
filter_pill = result['applied_filters'][0]
self.assertIn('not empty', filter_pill['link_text'].lower())

View File

@@ -80,6 +80,7 @@ class ClusterTypeBulkEditView(generic.BulkEditView):
@register_model_view(ClusterType, 'bulk_rename', path='rename', detail=False)
class ClusterTypeBulkRenameView(generic.BulkRenameView):
queryset = ClusterType.objects.all()
filterset = filtersets.ClusterTypeFilterSet
@register_model_view(ClusterType, 'bulk_delete', path='delete', detail=False)
@@ -158,6 +159,7 @@ class ClusterGroupBulkEditView(generic.BulkEditView):
@register_model_view(ClusterGroup, 'bulk_rename', path='rename', detail=False)
class ClusterGroupBulkRenameView(generic.BulkRenameView):
queryset = ClusterGroup.objects.all()
filterset = filtersets.ClusterGroupFilterSet
@register_model_view(ClusterGroup, 'bulk_delete', path='delete', detail=False)
@@ -277,6 +279,7 @@ class ClusterBulkEditView(generic.BulkEditView):
@register_model_view(Cluster, 'bulk_rename', path='rename', detail=False)
class ClusterBulkRenameView(generic.BulkRenameView):
queryset = Cluster.objects.all()
filterset = filtersets.ClusterFilterSet
@register_model_view(Cluster, 'bulk_delete', path='delete', detail=False)
@@ -437,6 +440,7 @@ class VirtualMachineBulkEditView(generic.BulkEditView):
@register_model_view(VirtualMachine, 'bulk_rename', path='rename', detail=False)
class VirtualMachineBulkRenameView(generic.BulkRenameView):
queryset = VirtualMachine.objects.all()
filterset = filtersets.VirtualMachineFilterSet
@register_model_view(VirtualMachine, 'bulk_delete', path='delete', detail=False)
@@ -539,6 +543,7 @@ class VMInterfaceBulkEditView(generic.BulkEditView):
@register_model_view(VMInterface, 'bulk_rename', path='rename', detail=False)
class VMInterfaceBulkRenameView(generic.BulkRenameView):
queryset = VMInterface.objects.all()
filterset = filtersets.VMInterfaceFilterSet
form = forms.VMInterfaceBulkRenameForm
@@ -602,6 +607,7 @@ class VirtualDiskBulkEditView(generic.BulkEditView):
@register_model_view(VirtualDisk, 'bulk_rename', path='rename', detail=False)
class VirtualDiskBulkRenameView(generic.BulkRenameView):
queryset = VirtualDisk.objects.all()
filterset = filtersets.VirtualDiskFilterSet
form = forms.VirtualDiskBulkRenameForm

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from vpn.models import L2VPN, L2VPNTermination
__all__ = (
@@ -17,7 +17,7 @@ L2VPN_TARGETS = """
"""
class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
class L2VPNTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
pk = columns.ToggleColumn()
name = tables.Column(
verbose_name=_('Name'),
@@ -47,7 +47,7 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
model = L2VPN
fields = (
'pk', 'name', 'slug', 'status', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
'tenant_group', 'description', 'contacts', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'status', 'identifier', 'type', 'description')

View File

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from vpn.models import *
__all__ = (
@@ -13,7 +13,7 @@ __all__ = (
)
class TunnelGroupTable(NetBoxTable):
class TunnelGroupTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
@@ -30,12 +30,13 @@ class TunnelGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = TunnelGroup
fields = (
'pk', 'id', 'name', 'tunnel_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'tunnel_count', 'description', 'slug', 'contacts', 'tags', 'actions',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'tunnel_count', 'description')
class TunnelTable(TenancyColumnsMixin, NetBoxTable):
class TunnelTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
@@ -68,7 +69,8 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable):
model = Tunnel
fields = (
'pk', 'id', 'name', 'group', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group',
'tunnel_id', 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated',
'tunnel_id', 'termination_count', 'description', 'contacts', 'comments', 'tags', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'group', 'status', 'encapsulation', 'tenant', 'terminations_count')

View File

@@ -62,6 +62,7 @@ class TunnelGroupBulkEditView(generic.BulkEditView):
@register_model_view(TunnelGroup, 'bulk_rename', path='rename', detail=False)
class TunnelGroupBulkRenameView(generic.BulkRenameView):
queryset = TunnelGroup.objects.all()
filterset = filtersets.TunnelGroupFilterSet
@register_model_view(TunnelGroup, 'bulk_delete', path='delete', detail=False)
@@ -131,6 +132,7 @@ class TunnelBulkEditView(generic.BulkEditView):
@register_model_view(Tunnel, 'bulk_rename', path='rename', detail=False)
class TunnelBulkRenameView(generic.BulkRenameView):
queryset = Tunnel.objects.all()
filterset = filtersets.TunnelFilterSet
@register_model_view(Tunnel, 'bulk_delete', path='delete', detail=False)
@@ -238,6 +240,7 @@ class IKEProposalBulkEditView(generic.BulkEditView):
@register_model_view(IKEProposal, 'bulk_rename', path='rename', detail=False)
class IKEProposalBulkRenameView(generic.BulkRenameView):
queryset = IKEProposal.objects.all()
filterset = filtersets.IKEProposalFilterSet
@register_model_view(IKEProposal, 'bulk_delete', path='delete', detail=False)
@@ -293,6 +296,7 @@ class IKEPolicyBulkEditView(generic.BulkEditView):
@register_model_view(IKEPolicy, 'bulk_rename', path='rename', detail=False)
class IKEPolicyBulkRenameView(generic.BulkRenameView):
queryset = IKEPolicy.objects.all()
filterset = filtersets.IKEPolicyFilterSet
@register_model_view(IKEPolicy, 'bulk_delete', path='delete', detail=False)
@@ -348,6 +352,7 @@ class IPSecProposalBulkEditView(generic.BulkEditView):
@register_model_view(IPSecProposal, 'bulk_rename', path='rename', detail=False)
class IPSecProposalBulkRenameView(generic.BulkRenameView):
queryset = IPSecProposal.objects.all()
filterset = filtersets.IPSecProposalFilterSet
@register_model_view(IPSecProposal, 'bulk_delete', path='delete', detail=False)
@@ -403,6 +408,7 @@ class IPSecPolicyBulkEditView(generic.BulkEditView):
@register_model_view(IPSecPolicy, 'bulk_rename', path='rename', detail=False)
class IPSecPolicyBulkRenameView(generic.BulkRenameView):
queryset = IPSecPolicy.objects.all()
filterset = filtersets.IPSecPolicyFilterSet
@register_model_view(IPSecPolicy, 'bulk_delete', path='delete', detail=False)
@@ -458,6 +464,7 @@ class IPSecProfileBulkEditView(generic.BulkEditView):
@register_model_view(IPSecProfile, 'bulk_rename', path='rename', detail=False)
class IPSecProfileBulkRenameView(generic.BulkRenameView):
queryset = IPSecProfile.objects.all()
filterset = filtersets.IPSecProfileFilterSet
@register_model_view(IPSecProfile, 'bulk_delete', path='delete', detail=False)
@@ -530,6 +537,7 @@ class L2VPNBulkEditView(generic.BulkEditView):
@register_model_view(L2VPN, 'bulk_rename', path='rename', detail=False)
class L2VPNBulkRenameView(generic.BulkRenameView):
queryset = L2VPN.objects.all()
filterset = filtersets.L2VPNFilterSet
@register_model_view(L2VPN, 'bulk_delete', path='delete', detail=False)

View File

@@ -71,6 +71,7 @@ class WirelessLANGroupBulkEditView(generic.BulkEditView):
@register_model_view(WirelessLANGroup, 'bulk_rename', path='rename', detail=False)
class WirelessLANGroupBulkRenameView(generic.BulkRenameView):
queryset = WirelessLANGroup.objects.all()
filterset = filtersets.WirelessLANGroupFilterSet
@register_model_view(WirelessLANGroup, 'bulk_delete', path='delete', detail=False)
@@ -146,6 +147,7 @@ class WirelessLANBulkEditView(generic.BulkEditView):
class WirelessLANBulkRenameView(generic.BulkRenameView):
queryset = WirelessLAN.objects.all()
field_name = 'ssid'
filterset = filtersets.WirelessLANFilterSet
@register_model_view(WirelessLAN, 'bulk_delete', path='delete', detail=False)
@@ -202,6 +204,7 @@ class WirelessLinkBulkEditView(generic.BulkEditView):
class WirelessLinkBulkRenameView(generic.BulkRenameView):
queryset = WirelessLink.objects.all()
field_name = 'ssid'
filterset = filtersets.WirelessLinkFilterSet
@register_model_view(WirelessLink, 'bulk_delete', path='delete', detail=False)

View File

@@ -3,7 +3,7 @@
[project]
name = "netbox"
version = "4.4.4"
version = "4.4.5"
requires-python = ">=3.10"
description = "The premier source of truth powering network automation."
readme = "README.md"

View File

@@ -28,16 +28,16 @@ mkdocstrings==0.30.1
mkdocstrings-python==1.18.2
netaddr==1.3.0
nh3==0.3.1
Pillow==11.3.0
psycopg[c,pool]==3.2.10
Pillow==12.0.0
psycopg[c,pool]==3.2.12
PyYAML==6.0.3
requests==2.32.5
rq==2.6.0
social-auth-app-django==5.6.0
social-auth-core==4.8.1
sorl-thumbnail==12.11.0
strawberry-graphql==0.283.3
strawberry-graphql-django==0.66.2
strawberry-graphql==0.284.1
strawberry-graphql-django==0.67.0
svgwrite==1.4.3
tablib==3.9.0
tzdata==2025.2