Compare commits

..

44 Commits

Author SHA1 Message Date
Jason Novinger
431eeb11ba Merge 9491356c7e into 20c260b126 2025-12-04 14:47:01 -06:00
Jason Novinger
9491356c7e Require registered filterset for filter modifier enhancements
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
Updates FilterModifierMixin to only enhance form fields when the
associated model has a registered filterset. This provides plugin
safety by ensuring unregistered plugin filtersets fall back to
simple filters without lookup modifiers.

Test changes:
- Create TestModel and TestFilterSet using BaseFilterSet for
automatic lookup generation
- Import dcim.filtersets to ensure Device filterset registration
- Adjust tag field expectations to match actual Device filterset
(has exact/n but not empty lookups)
2025-12-04 14:46:27 -06:00
Jason Novinger
1cb6245454 Move register_filterset to netbox.plugins.registration 2025-12-04 14:46:27 -06:00
Jason Novinger
64d5fb1861 Check all expected lookups in field enhancement tests 2025-12-04 14:46:27 -06:00
Jason Novinger
dacaf7bd2d Match complete tags in widget rendering test assertions 2025-12-04 14:46:27 -06:00
Jason Novinger
807a22db23 Remove comparison symbols from numeric filter labels 2025-12-04 14:46:27 -06:00
Jason Novinger
2ef6d7f76a Add guard for FilterModifierWidget with no lookups 2025-12-04 14:46:27 -06:00
Jason Novinger
33896c60f2 Verifies that filter pills for exact matches (no lookup
Add test for exact lookup filter pill rendering
2025-12-04 14:46:27 -06:00
Jason Novinger
8de87140cc Fix applied_filters template tag to use field-type-specific lookup labelsresolves
E.g. resolves gt="after" for dates vs "greater than" for numbers
2025-12-04 14:46:27 -06:00
Jason Novinger
a2b1796ffc Switch to sentence case for filter pill text 2025-12-04 14:46:27 -06:00
Jason Novinger
2c73593f1a Removed explicit checks against QueryField and [Null]BooleanField
I did add them to FORM_FIELD_LOOKUPS, though, to underscore that they
were considered and are intentially empty for future devs.
2025-12-04 14:46:27 -06:00
Jason Novinger
d1b1bbb62d Fix filterset registration for doubly-registered models 2025-12-04 14:46:27 -06:00
Jason Novinger
2af3e42606 Include MODIFIER_EMPTY_FALSE/_TRUE in __all__
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-12-04 14:46:27 -06:00
Jason Novinger
1c87c191b0 Support filter modifiers for ChoiceField 2025-12-04 14:46:27 -06:00
Jason Novinger
9990d9c44f Enables filter modifiers on APISelect based fields 2025-12-04 14:46:27 -06:00
Jason Novinger
50ced18a87 Remove unused star import, leftover from earlier work 2025-12-04 14:46:27 -06:00
Jason Novinger
864c7e952d Update app registry for new filtersets store 2025-12-04 14:46:27 -06:00
Jason Novinger
c62c97052d Remove unneeded imports left from earlier registry work 2025-12-04 14:46:27 -06:00
Jason Novinger
fe8563d625 Refactor register_filterset to be more generic and simple 2025-12-04 14:46:27 -06:00
Jason Novinger
c419fe08d4 Address PR feedback: refactor brittle test for APISelect useage
Now checks if widget is actually APISelect, rather than trying to infer
from the class name.
2025-12-04 14:46:27 -06:00
Jason Novinger
0810333334 Fix registry pattern to use model identifiers as keys
Changed filterset registration to use model identifiers ('{app_label}.{model_name}')
as registry keys instead of form classes, matching NetBox's pattern for search indexes.
2025-12-04 14:46:27 -06:00
Jason Novinger
38aae88757 Address PR feedback: Rename FilterModifierWidget parameter to widget 2025-12-04 14:46:27 -06:00
Jason Novinger
6a4680c4e3 Address PR feedback: Refactor applied_filters to use FORM_FIELD_LOOKUPS 2025-12-04 14:46:27 -06:00
Jason Novinger
1ed357de3b Address PR feedback: Refactor and consolidate field filtering logic
Consolidated field enhancement logic in FilterModifierMixin by:
- Creating QueryField marker type (CharField subclass) for search fields
- Updating FilterForm and NetBoxModelFilterSetForm to use QueryField for 'q'
- Moving all skip logic into _get_lookup_choices() to return empty list for
  fields that shouldn't be enhanced
- Removing separate _should_skip_field() method
- Removing unused field_name parameter from _get_lookup_choices()
- Replacing hardcoded field name check ('q') with type-based detection
2025-12-04 14:46:27 -06:00
Jason Novinger
c7c8cfd5f5 Address PR feedback: Move FORM_FIELD_LOOKUPS to module-level constant
Extracts the field type to lookup mappings from FilterModifierMixin class
attribute to a module-level constant for better reusability.
2025-12-04 14:46:27 -06:00
Jason Novinger
5a2605ca5d Fix filter modifier form submission bug with 'action' field collision
Forms with a field named "action" (e.g., ObjectChangeFilterForm) were causing
the form.action property to be shadowed by the field element, resulting in
[object HTMLSelectElement] appearing in the URL path.

Use form.getAttribute('action') instead of form.action to reliably retrieve
the form's action URL without collision from form fields.

Fixes form submission on /core/changelog/ and any other forms with an 'action'
field using filter modifiers.
2025-12-04 14:46:24 -06:00
Jason Novinger
5757f1b94e Address PR feedback: Move FilterModifierMixin into base filter form classes
Incorporates FilterModifierMixin into NetBoxModelFilterSetForm and FilterForm,
making filter modifiers automatic for all filter forms throughout the application.
2025-12-04 14:46:03 -06:00
Jason Novinger
e1404daf8c Address PR feedback: Replace global filterset mappings with registry 2025-12-04 14:46:03 -06:00
Jason Novinger
7c66636f30 Add ChoiceField support to FilterModifierMixin
Enable filter modifiers for single-choice ChoiceFields in addition to the
existing MultipleChoiceField support. ChoiceFields can now display modifier
dropdowns with "Is", "Is Not", "Is Empty", and "Is Not Empty" options when
the corresponding FilterSet defines those lookups.

The mixin correctly verifies lookup availability against the FilterSet, so
modifiers only appear when multiple lookup options are actually supported.
Currently most FilterSets only define 'exact' for single-choice fields, but
this change enables future FilterSet enhancements to expose additional
lookups for ChoiceFields.
2025-12-04 14:46:03 -06:00
Jason Novinger
fb768d731f Enable filter form modifiers on Extras models 2025-12-04 14:46:03 -06:00
Jason Novinger
0cc2d523d4 Enable filter form modifiers on Core models 2025-12-04 14:46:03 -06:00
Jason Novinger
e18e8c87eb Enable filter form modifiers on Users models 2025-12-04 14:46:03 -06:00
Jason Novinger
28fcb94da8 Enable filter form modifiers on Circuit models 2025-12-04 14:46:03 -06:00
Jason Novinger
69b0e3724f Enable filter form modifiers on Virtualization models 2025-12-04 14:46:03 -06:00
Jason Novinger
f1f24087d4 Enable filter form modifiers on VPN models 2025-12-04 14:46:03 -06:00
Jason Novinger
96eaa53659 Enable filter form modifiers on IPAM models 2025-12-04 14:46:03 -06:00
Jason Novinger
8e64482ff7 Enable filter form modifiers on Wireless models 2025-12-04 14:46:03 -06:00
Jason Novinger
f32f3e65ab Enable filter form modifiers on Tenancy models 2025-12-04 14:46:03 -06:00
Jason Novinger
00cf5f58b5 Enable filter form modifiers on DCIM models 2025-12-04 14:46:03 -06:00
Jason Novinger
f068d2d65c Fix CircuitFilterForm inheritance 2025-12-04 14:46:03 -06:00
Jason Novinger
83080996ab Fix import order 2025-12-04 14:45:58 -06:00
Jason Novinger
f4d3bac519 Remove extraneous TS comments 2025-12-04 14:45:30 -06:00
Jason Novinger
8445a63786 Fixes #7604: Add filter modifier dropdowns for advanced lookup operators
Implements dynamic filter modifier UI that allows users to select lookup operators
(exact, contains, starts with, regex, negation, empty/not empty) directly in filter
forms without manual URL parameter editing.

Supports filters for all scalar types and strings, as well as some
related object filters. Explicitly does not support filters on fields
that use APIWidget. That has been broken out in to follow up work.

**Backend:**
- FilterModifierWidget: Wraps form widgets with lookup modifier dropdown
- FilterModifierMixin: Auto-enhances filterset fields with appropriate lookups
- Extended lookup support: Adds negation (n), regex, iregex, empty_true/false lookups
- Field-type-aware: CharField gets text lookups, IntegerField gets comparison operators, etc.

**Frontend:**
- TypeScript handler syncs modifier dropdown with URL parameters
- Dynamically updates form field names (serial → serial__ic) on modifier change
- Flexible-width modifier dropdowns with semantic CSS classes
2025-12-04 14:45:20 -06:00
Jeremy Stretch
20c260b126 Closes #20572: Update all development frontend dependencies (#20909)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-12-04 09:00:57 -08:00
25 changed files with 99169 additions and 2511 deletions

View File

@@ -7,11 +7,11 @@ from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Interface, Location, Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from netbox.plugins.registration import register_filterset
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
)
from utilities.filtersets import register_filterset
from .choices import *
from .models import *

View File

@@ -4,10 +4,10 @@ from django.db.models import Q
from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet
from netbox.plugins.registration import register_filterset
from netbox.utils import get_data_backend_choices
from users.models import User
from utilities.filters import ContentTypeFilter
from utilities.filtersets import register_filterset
from .choices import *
from .models import *

View File

@@ -14,6 +14,7 @@ from netbox.filtersets import (
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet,
OrganizationalModelFilterSet, PrimaryModelFilterSet, NetBoxModelFilterSet,
)
from netbox.plugins.registration import register_filterset
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from tenancy.models import *
from users.filterset_mixins import OwnerFilterMixin
@@ -22,7 +23,6 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
from utilities.filtersets import register_filterset
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
from vpn.models import L2VPN
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices

View File

@@ -6,13 +6,13 @@ from django.utils.translation import gettext as _
from core.models import DataSource, ObjectType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet, PrimaryModelFilterSet
from netbox.plugins.registration import register_filterset
from tenancy.models import Tenant, TenantGroup
from users.filterset_mixins import OwnerFilterMixin
from users.models import Group, User
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
)
from utilities.filtersets import register_filterset
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import *
from .filters import TagFilter, TagIDFilter

View File

@@ -14,12 +14,11 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup
from netbox.filtersets import (
ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, PrimaryModelFilterSet,
)
from netbox.plugins.registration import register_filterset
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
from utilities.filtersets import register_filterset
from virtualization.models import VirtualMachine, VMInterface
from vpn.models import L2VPN
from .choices import *

View File

@@ -7,6 +7,7 @@ from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension
__all__ = (
'register_filterset',
'register_graphql_schema',
'register_menu',
'register_menu_items',
@@ -44,6 +45,18 @@ def register_template_extensions(class_list):
registry['plugins']['template_extensions'][model].append(template_extension)
def register_filterset(filterset_class):
"""
Decorator for registering a FilterSet with the application registry.
Uses model identifier as key to match search index pattern.
"""
model = filterset_class._meta.model
label = f'{model._meta.app_label}.{model._meta.model_name}'
registry['filtersets'][label] = filterset_class
return filterset_class
def register_menu(menu):
if not isinstance(menu, PluginMenu):
raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(item=menu))

View File

@@ -1,3 +0,0 @@
dist
node_modules
.cache

View File

@@ -1,53 +0,0 @@
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:import/typescript",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"env": {
"browser": true,
"es6": true,
"node": true
},
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"ecmaFeatures": {
"arrowFunctions": true
}
},
"plugins": ["@typescript-eslint", "prettier"],
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"typescript": {}
}
},
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"no-unused-vars": "off",
"no-inner-declarations": "off",
"comma-dangle": ["error", "always-multiline"],
"global-require": "off",
"import/no-dynamic-require": "off",
"import/prefer-default-export": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-empty-interface": [
"error",
{
"allowSingleExtends": true
}
]
}
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,86 @@
import { defineConfig, globalIgnores } from "eslint/config";
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import prettier from "eslint-plugin-prettier";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([
globalIgnores(['**/dist', '**/node_modules', '**/.cache']),
{
extends: fixupConfigRules(
compat.extends(
'eslint:recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'prettier',
),
),
plugins: {
'@typescript-eslint': fixupPluginRules(typescriptEslint),
prettier: fixupPluginRules(prettier),
},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: tsParser,
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
arrowFunctions: true,
},
},
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {},
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'no-unused-vars': 'off',
'no-inner-declarations': 'off',
'comma-dangle': ['error', 'always-multiline'],
'global-require': 'off',
'import/no-dynamic-require': 'off',
'import/prefer-default-export': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
},
},
]);

View File

@@ -1,6 +1,7 @@
{
"name": "netbox",
"version": "4.4.0",
"type": "module",
"version": "4.5.0",
"main": "dist/netbox.js",
"license": "Apache-2.0",
"private": true,
@@ -8,14 +9,14 @@
"netbox-graphiql"
],
"scripts": {
"bundle": "node bundle.js",
"bundle:styles": "node bundle.js --styles",
"bundle:scripts": "node bundle.js --scripts",
"bundle": "node bundle.cjs",
"bundle:styles": "node bundle.cjs --styles",
"bundle:scripts": "node bundle.cjs --scripts",
"format": "yarn format:scripts && yarn format:styles",
"format:scripts": "prettier -w src/**/*.ts",
"format:styles": "prettier -w styles/**/*.scss",
"validate": "yarn validate:types && yarn validate:lint",
"validate:lint": "eslint -c .eslintrc ./src/**/*.ts",
"validate:lint": "eslint ./src/**/*.ts",
"validate:types": "tsc --noEmit",
"validate:formatting": "yarn validate:formatting:scripts && yarn validate:formatting:styles",
"validate:formatting:styles": "prettier -c styles/**/*.scss",
@@ -36,20 +37,24 @@
"typeface-roboto-mono": "1.1.13"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.1",
"@types/bootstrap": "5.2.10",
"@types/cookie": "^0.6.0",
"@types/node": "^22.3.0",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"esbuild": "^0.25.11",
"@types/cookie": "^1.0.0",
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"esbuild": "^0.27.0",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "<9.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.1",
"prettier": "^3.3.3",
"typescript": "<5.5"
"globals": "^16.5.0",
"prettier": "^3.7.3",
"typescript": "^5.9.3"
},
"resolutions": {
"@types/bootstrap/**/@popperjs/core": "^2.11.6"

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,8 @@ from django.utils.translation import gettext as _
from netbox.filtersets import (
NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
)
from netbox.plugins.registration import register_filterset
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from utilities.filtersets import register_filterset
from .models import *
__all__ = (

View File

@@ -6,9 +6,9 @@ from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.models import NotificationGroup
from netbox.filtersets import BaseFilterSet
from netbox.plugins.registration import register_filterset
from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
from utilities.filters import ContentTypeFilter
from utilities.filtersets import register_filterset
__all__ = (
'GroupFilterSet',

View File

@@ -1,13 +0,0 @@
from netbox.registry import registry
def register_filterset(filterset_class):
"""
Decorator for registering a FilterSet with the application registry.
Uses model identifier as key to match search index pattern.
"""
model = filterset_class._meta.model
label = f'{model._meta.app_label}.{model._meta.model_name}'
registry['filtersets'][label] = filterset_class
return filterset_class

View File

@@ -39,20 +39,20 @@ FORM_FIELD_LOOKUPS = {
forms.IntegerField: [
('exact', _('is')),
('n', _('is not')),
('gt', _('greater than (>)')),
('gte', _('at least (≥)')),
('lt', _('less than (<)')),
('lte', _('at most (≤)')),
('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 (≤)')),
('gt', _('greater than')),
('gte', _('at least')),
('lt', _('less than')),
('lte', _('at most')),
(MODIFIER_EMPTY_TRUE, _('is empty')),
(MODIFIER_EMPTY_FALSE, _('is not empty')),
],
@@ -196,11 +196,11 @@ class FilterModifierMixin:
if filterset:
lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset)
if len(lookups) > 1:
field.widget = FilterModifierWidget(
widget=field.widget,
lookups=lookups
)
if len(lookups) > 1:
field.widget = FilterModifierWidget(
widget=field.widget,
lookups=lookups
)
def _get_lookup_choices(self, field):
"""Determine the available lookup choices for a given field.

View File

@@ -1,13 +1,15 @@
<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>
{% if widget.lookups %}
{# 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>
{% endif %}
{# Original widget - rendered exactly as it would be without our wrapper #}
<div class="ms-2 flex-grow-1 filter-value-container">

View File

@@ -1,10 +1,14 @@
from django import forms
from django.db import models
from django.http import QueryDict
from django.template import Context
from django.test import RequestFactory, TestCase
import dcim.filtersets # noqa: F401 - Import to register Device filterset
from dcim.forms.filtersets import DeviceFilterForm
from dcim.models import Device
from netbox.filtersets import BaseFilterSet
from netbox.plugins.registration import register_filterset
from users.models import User
from utilities.forms.fields import TagFilterField
from utilities.forms.mixins import FilterModifierMixin
@@ -12,6 +16,28 @@ from utilities.forms.widgets import FilterModifierWidget
from utilities.templatetags.helpers import applied_filters
# Test model for FilterModifierMixin tests
class TestModel(models.Model):
"""Dummy model for testing filter modifiers."""
char_field = models.CharField(max_length=100, blank=True)
integer_field = models.IntegerField(null=True, blank=True)
decimal_field = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
date_field = models.DateField(null=True, blank=True)
boolean_field = models.BooleanField(default=False)
class Meta:
app_label = 'utilities'
managed = False # Don't create actual database table
# Test filterset using BaseFilterSet to automatically generate lookups
@register_filterset
class TestFilterSet(BaseFilterSet):
class Meta:
model = TestModel
fields = ['char_field', 'integer_field', 'decimal_field', 'date_field', 'boolean_field']
class FilterModifierWidgetTest(TestCase):
"""Tests for FilterModifierWidget value extraction and rendering."""
@@ -85,10 +111,8 @@ class FilterModifierWidgetTest(TestCase):
# 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)
self.assertIn('<option value="exact" selected>Is</option>', html)
self.assertIn('<option value="ic">Contains</option>', html)
# Should contain original input
self.assertIn('type="text"', html)
@@ -102,127 +126,78 @@ class FilterModifierMixinTest(TestCase):
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)
char_field = forms.CharField(required=False)
model = TestModel
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)
self.assertIsInstance(form.fields['char_field'].widget, FilterModifierWidget)
lookup_codes = [lookup[0] for lookup in form.fields['char_field'].widget.lookups]
expected_lookups = ['exact', 'n', 'ic', 'isw', 'iew', 'ie', 'regex', 'iregex', 'empty_true', 'empty_false']
self.assertEqual(lookup_codes, expected_lookups)
def test_mixin_skips_boolean_fields(self):
"""Boolean fields should not be enhanced."""
class TestForm(FilterModifierMixin, forms.Form):
active = forms.BooleanField(required=False)
boolean_field = forms.BooleanField(required=False)
model = TestModel
form = TestForm()
self.assertNotIsInstance(form.fields['active'].widget, FilterModifierWidget)
self.assertNotIsInstance(form.fields['boolean_field'].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)
model = 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)
# Device filterset has tag and tag__n but not tag__empty
expected_lookups = ['exact', 'n']
self.assertEqual(tag_lookups, expected_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)
integer_field = forms.IntegerField(required=False)
model = TestModel
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)
self.assertIsInstance(form.fields['integer_field'].widget, FilterModifierWidget)
lookup_codes = [lookup[0] for lookup in form.fields['integer_field'].widget.lookups]
expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false']
self.assertEqual(lookup_codes, expected_lookups)
def test_mixin_adds_isnull_lookup_to_all_fields(self):
"""All field types should include isnull (empty/not empty) lookup."""
def test_mixin_enhances_decimal_field(self):
"""DecimalField should be enhanced with comparison modifiers."""
class TestForm(FilterModifierMixin, forms.Form):
name = forms.CharField(required=False)
count = forms.IntegerField(required=False)
created = forms.DateField(required=False)
decimal_field = forms.DecimalField(required=False)
model = TestModel
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)
self.assertIsInstance(form.fields['decimal_field'].widget, FilterModifierWidget)
lookup_codes = [lookup[0] for lookup in form.fields['decimal_field'].widget.lookups]
expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false']
self.assertEqual(lookup_codes, expected_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."""
def test_mixin_enhances_date_field(self):
"""DateField should be enhanced with date-appropriate modifiers."""
class TestForm(FilterModifierMixin, forms.Form):
name = forms.CharField(required=False)
date_field = forms.DateField(required=False)
model = TestModel
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)
self.assertIsInstance(form.fields['date_field'].widget, FilterModifierWidget)
lookup_codes = [lookup[0] for lookup in form.fields['date_field'].widget.lookups]
expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false']
self.assertEqual(lookup_codes, expected_lookups)
class ExtendedLookupFilterPillsTest(TestCase):
@@ -261,6 +236,26 @@ class ExtendedLookupFilterPillsTest(TestCase):
filter_pill = result['applied_filters'][0]
self.assertIn('matches pattern', filter_pill['link_text'].lower())
def test_exact_lookup_filter_pill(self):
"""Filter pill should show field label and value without lookup modifier for exact match."""
query_params = QueryDict('serial=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]
# Should not contain lookup modifier text
self.assertNotIn('is not', filter_pill['link_text'].lower())
self.assertNotIn('matches pattern', filter_pill['link_text'].lower())
self.assertNotIn('contains', filter_pill['link_text'].lower())
# Should contain field label and value
self.assertIn('Serial', filter_pill['link_text'])
self.assertIn('ABC123', filter_pill['link_text'])
class EmptyLookupTest(TestCase):
"""Tests for empty (is empty/not empty) lookup support."""

View File

@@ -10,11 +10,10 @@ from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from netbox.plugins.registration import register_filterset
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from users.filterset_mixins import OwnerFilterMixin
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from utilities.filtersets import register_filterset
from .choices import *
from .models import *

View File

@@ -6,9 +6,9 @@ from core.models import ObjectType
from dcim.models import Device, Interface
from ipam.models import IPAddress, RouteTarget, VLAN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from netbox.plugins.registration import register_filterset
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from utilities.filtersets import register_filterset
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .models import *

View File

@@ -6,9 +6,9 @@ from dcim.base_filtersets import ScopedFilterSet
from dcim.models import Interface
from ipam.models import VLAN
from netbox.filtersets import NestedGroupModelFilterSet, PrimaryModelFilterSet
from netbox.plugins.registration import register_filterset
from tenancy.filtersets import TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from utilities.filtersets import register_filterset
from .choices import *
from .models import *