mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 10:08:44 -06:00
Compare commits
36 Commits
431eeb11ba
...
b0d7bb3d18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d7bb3d18 | ||
|
|
2f3d7b1c5c | ||
|
|
212ec72f22 | ||
|
|
905a516d23 | ||
|
|
d4535df043 | ||
|
|
93f916d115 | ||
|
|
592a0f9653 | ||
|
|
d1788c1d2f | ||
|
|
5d309ce85f | ||
|
|
ebdf3f98d5 | ||
|
|
46f9b2ccb4 | ||
|
|
64f38334be | ||
|
|
cf0a13f535 | ||
|
|
e2b9317be1 | ||
|
|
316c12c6a9 | ||
|
|
d900bfc312 | ||
|
|
3dcc299cda | ||
|
|
626155c5f5 | ||
|
|
711984a825 | ||
|
|
c298108c1c | ||
|
|
ab70c4b2c1 | ||
|
|
de96bc8770 | ||
|
|
e9bacf9a2e | ||
|
|
aa061f958f | ||
|
|
a5b5dd0357 | ||
|
|
f0c6b186ce | ||
|
|
cf2608a4ff | ||
|
|
9c436ee0cd | ||
|
|
4199aeb21e | ||
|
|
a7a1a95955 | ||
|
|
63ea883717 | ||
|
|
d5f33bd4b4 | ||
|
|
8291eb5c13 | ||
|
|
93c5c3b846 | ||
|
|
e544976534 | ||
|
|
e19591484c |
@@ -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 *
|
||||
|
||||
|
||||
@@ -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 *
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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))
|
||||
|
||||
3
netbox/project-static/.eslintignore
Normal file
3
netbox/project-static/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
.cache
|
||||
53
netbox/project-static/.eslintrc
Normal file
53
netbox/project-static/.eslintrc
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3025
netbox/project-static/dist/graphiql/graphiql.min.css
vendored
3025
netbox/project-static/dist/graphiql/graphiql.min.css
vendored
File diff suppressed because it is too large
Load Diff
96214
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
96214
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js
vendored
2
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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
@@ -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__ = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 *
|
||||
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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 *
|
||||
|
||||
|
||||
Reference in New Issue
Block a user