Compare commits

..

36 Commits

Author SHA1 Message Date
Jason Novinger
b0d7bb3d18 Merge 2f3d7b1c5c into 7bca9f5d6d 2025-12-04 00:08:54 +00:00
Jason Novinger
2f3d7b1c5c Fix applied_filters template tag to use field-type-specific lookup labelsresolves
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
E.g. resolves gt="after" for dates vs "greater than" for numbers
2025-11-25 15:24:03 -06:00
Jason Novinger
212ec72f22 Switch to sentence case for filter pill text 2025-11-25 15:24:03 -06:00
Jason Novinger
905a516d23 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-11-25 15:24:03 -06:00
Jason Novinger
d4535df043 Fix filterset registration for doubly-registered models 2025-11-25 15:24:03 -06:00
Jason Novinger
93f916d115 Include MODIFIER_EMPTY_FALSE/_TRUE in __all__
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-11-25 15:24:02 -06:00
Jason Novinger
592a0f9653 Support filter modifiers for ChoiceField 2025-11-25 15:24:02 -06:00
Jason Novinger
d1788c1d2f Enables filter modifiers on APISelect based fields 2025-11-25 15:24:02 -06:00
Jason Novinger
5d309ce85f Remove unused star import, leftover from earlier work 2025-11-25 15:24:02 -06:00
Jason Novinger
ebdf3f98d5 Update app registry for new filtersets store 2025-11-25 15:24:02 -06:00
Jason Novinger
46f9b2ccb4 Remove unneeded imports left from earlier registry work 2025-11-25 15:24:02 -06:00
Jason Novinger
64f38334be Refactor register_filterset to be more generic and simple 2025-11-25 15:24:02 -06:00
Jason Novinger
cf0a13f535 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-11-25 15:24:02 -06:00
Jason Novinger
e2b9317be1 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-11-25 15:24:02 -06:00
Jason Novinger
316c12c6a9 Address PR feedback: Rename FilterModifierWidget parameter to widget 2025-11-25 15:24:02 -06:00
Jason Novinger
d900bfc312 Address PR feedback: Refactor applied_filters to use FORM_FIELD_LOOKUPS 2025-11-25 15:24:02 -06:00
Jason Novinger
3dcc299cda 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-11-25 15:24:02 -06:00
Jason Novinger
626155c5f5 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-11-25 15:24:02 -06:00
Jason Novinger
711984a825 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-11-25 15:24:02 -06:00
Jason Novinger
c298108c1c 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-11-25 15:24:02 -06:00
Jason Novinger
ab70c4b2c1 Address PR feedback: Replace global filterset mappings with registry 2025-11-25 15:24:02 -06:00
Jason Novinger
de96bc8770 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-11-25 15:24:02 -06:00
Jason Novinger
e9bacf9a2e Enable filter form modifiers on Extras models 2025-11-25 15:24:02 -06:00
Jason Novinger
aa061f958f Enable filter form modifiers on Core models 2025-11-25 15:24:02 -06:00
Jason Novinger
a5b5dd0357 Enable filter form modifiers on Users models 2025-11-25 15:24:02 -06:00
Jason Novinger
f0c6b186ce Enable filter form modifiers on Circuit models 2025-11-25 15:24:02 -06:00
Jason Novinger
cf2608a4ff Enable filter form modifiers on Virtualization models 2025-11-25 15:24:02 -06:00
Jason Novinger
9c436ee0cd Enable filter form modifiers on VPN models 2025-11-25 15:24:02 -06:00
Jason Novinger
4199aeb21e Enable filter form modifiers on IPAM models 2025-11-25 15:24:02 -06:00
Jason Novinger
a7a1a95955 Enable filter form modifiers on Wireless models 2025-11-25 15:24:02 -06:00
Jason Novinger
63ea883717 Enable filter form modifiers on Tenancy models 2025-11-25 15:24:02 -06:00
Jason Novinger
d5f33bd4b4 Enable filter form modifiers on DCIM models 2025-11-25 15:24:02 -06:00
Jason Novinger
8291eb5c13 Fix CircuitFilterForm inheritance 2025-11-25 15:24:02 -06:00
Jason Novinger
93c5c3b846 Fix import order 2025-11-25 15:24:02 -06:00
Jason Novinger
e544976534 Remove extraneous TS comments 2025-11-25 15:24:02 -06:00
Jason Novinger
e19591484c 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-11-25 15:23:54 -06:00
25 changed files with 2520 additions and 99178 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,7 +14,6 @@ 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
@@ -23,6 +22,7 @@ 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,11 +14,12 @@ 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,7 +7,6 @@ from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension
__all__ = (
'register_filterset',
'register_graphql_schema',
'register_menu',
'register_menu_items',
@@ -45,18 +44,6 @@ 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

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

View File

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

@@ -1,86 +0,0 @@
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,7 +1,6 @@
{
"name": "netbox",
"type": "module",
"version": "4.5.0",
"version": "4.4.0",
"main": "dist/netbox.js",
"license": "Apache-2.0",
"private": true,
@@ -9,14 +8,14 @@
"netbox-graphiql"
],
"scripts": {
"bundle": "node bundle.cjs",
"bundle:styles": "node bundle.cjs --styles",
"bundle:scripts": "node bundle.cjs --scripts",
"bundle": "node bundle.js",
"bundle:styles": "node bundle.js --styles",
"bundle:scripts": "node bundle.js --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 ./src/**/*.ts",
"validate:lint": "eslint -c .eslintrc ./src/**/*.ts",
"validate:types": "tsc --noEmit",
"validate:formatting": "yarn validate:formatting:scripts && yarn validate:formatting:styles",
"validate:formatting:styles": "prettier -c styles/**/*.scss",
@@ -37,24 +36,20 @@
"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": "^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",
"@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",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint": "<9.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.1",
"globals": "^16.5.0",
"prettier": "^3.7.3",
"typescript": "^5.9.3"
"prettier": "^3.3.3",
"typescript": "<5.5"
},
"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

@@ -0,0 +1,13 @@
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,15 +1,13 @@
<div class="d-flex filter-modifier-group">
{% 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 %}
{# 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">

View File

@@ -1,14 +1,10 @@
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
@@ -16,28 +12,6 @@ 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."""
@@ -111,8 +85,10 @@ class FilterModifierWidgetTest(TestCase):
# Should contain modifier dropdown
self.assertIn('class="form-select modifier-select"', html)
self.assertIn('data-field="serial"', html)
self.assertIn('<option value="exact" selected>Is</option>', html)
self.assertIn('<option value="ic">Contains</option>', 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)
@@ -126,78 +102,127 @@ 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):
char_field = forms.CharField(required=False)
model = TestModel
name = forms.CharField(required=False)
form = TestForm()
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)
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):
boolean_field = forms.BooleanField(required=False)
model = TestModel
active = forms.BooleanField(required=False)
form = TestForm()
self.assertNotIsInstance(form.fields['boolean_field'].widget, FilterModifierWidget)
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)
model = Device
form = TestForm()
self.assertIsInstance(form.fields['tag'].widget, FilterModifierWidget)
tag_lookups = [lookup[0] for lookup in form.fields['tag'].widget.lookups]
# Device filterset has tag and tag__n but not tag__empty
expected_lookups = ['exact', 'n']
self.assertEqual(tag_lookups, expected_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):
integer_field = forms.IntegerField(required=False)
model = TestModel
count = forms.IntegerField(required=False)
form = TestForm()
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)
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_enhances_decimal_field(self):
"""DecimalField should be enhanced with comparison modifiers."""
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):
decimal_field = forms.DecimalField(required=False)
model = TestModel
name = forms.CharField(required=False)
count = forms.IntegerField(required=False)
created = forms.DateField(required=False)
form = TestForm()
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)
# 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)
def test_mixin_enhances_date_field(self):
"""DateField should be enhanced with date-appropriate modifiers."""
# 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):
date_field = forms.DateField(required=False)
model = TestModel
name = forms.CharField(required=False)
form = TestForm()
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)
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):
@@ -236,26 +261,6 @@ 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,10 +10,11 @@ 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 *