Skip to content

Fixes #639: Respect filter_logic in generated filtersets - #644

Open
bctiemann wants to merge 2 commits into
mainfrom
639-filter-logic-ignored
Open

Fixes #639: Respect filter_logic in generated filtersets#644
bctiemann wants to merge 2 commits into
mainfrom
639-filter-logic-ignored

Conversation

@bctiemann

@bctiemann bctiemann commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes: #639

Summary

  • CustomObjectTypeField.filter_logic has existed since this plugin's earliest commits, but was never actually read by filterset generation. build_filter_for_field() always built the base text/longtext/url filter with a hardcoded lookup_expr="icontains", regardless of what filter_logic was set to.
  • This silently broke suffix lookups (e.g. ?f1__isw=foo, ?f1__iew=foo, ?f1__ic=foo): NetBox core's BaseFilterSet.get_additional_lookups() only augments a filter whose own lookup_expr is one of a small fixed set ('exact', 'iexact', 'in', 'contains') -- "icontains" is not among them -- so those suffix filters were never registered at all, and the unrecognized query param was silently ignored, returning every row unfiltered instead of raising an error or actually filtering.
  • The fix mirrors NetBox core's own CustomField.to_filter() convention exactly, since this plugin's filter_logic field was modeled on it in the first place:
    • "loose" (the default): base filter keeps using icontains, unchanged from current behavior.
    • "exact": drops the lookup_expr override (CharFilter's own default is "exact"), which is what makes the base filter eligible for get_additional_lookups() to generate the __n/__ic/__isw/__iew/__ie/__empty/__regex suffix filters.
    • "disabled": skip building a filter for the field entirely, matching NetBox core's own universal (type-agnostic) skip for disabled custom fields. This was also silently ignored before; now it does what the UI has always documented it as doing.
  • Applies to TYPE_TEXT, TYPE_LONGTEXT, and TYPE_URL -- the same three types NetBox core's own filter_logic switch covers. TYPE_JSON keeps its existing icontains-only behavior (no core-NetBox "exact" analog to mirror).

Test plan

  • Added TextFieldFilterLogicTestCase covering: loose bare-filter unchanged, loose __isw not registered (documents the intentional NetBox-matching limit), exact bare-filter is exact match, exact __isw/__iew/__ic all correctly filter, disabled has no filter registered at all, disabled query param has no effect.
  • Confirmed the new tests actually fail without the fix (5 of 8 fail against the unmodified code), and pass with it.
  • Full test_filtersets.py module (128 tests) passes clean.
  • test_api.py, test_forms.py, test_views.py pass clean apart from 4 pre-existing, unrelated local-environment failures (netbox_branching importable but not enabled in this environment's PLUGINS list).
  • ruff check clean.

CustomObjectTypeField.filter_logic has existed since the plugin's
earliest commits but was never actually read by filterset generation.
build_filter_for_field() always built the base text/longtext/url
filter with a hardcoded lookup_expr="icontains", regardless of what
filter_logic was set to.

This silently broke suffix lookups (e.g. ?f1__isw=foo, ?f1__iew=foo,
?f1__ic=foo): BaseFilterSet.get_additional_lookups() only augments a
filter whose own lookup_expr is one of a small fixed set ('exact',
'iexact', 'in', 'contains') -- "icontains" is not among them -- so
those suffix filters were never registered at all, and the
unrecognized query param was silently ignored, returning every row
unfiltered instead of raising an error or filtering correctly.

Fix mirrors NetBox core's own CustomField.to_filter() convention
exactly, since this plugin's filter_logic field was modeled on it in
the first place:
- "loose" (the default): base filter keeps using icontains, unchanged
  from current behavior.
- "exact": drops the lookup_expr override (CharFilter's own default is
  "exact"), which is what makes the base filter eligible for
  get_additional_lookups() to generate the __n/__ic/__isw/__iew/__ie/
  __empty/__regex suffix filters.
- "disabled": skip building a filter for the field entirely, matching
  NetBox core's own universal (type-agnostic) skip for disabled custom
  fields. This was also silently ignored before; now it does what the
  UI has always documented it as doing.

Applies to TYPE_TEXT, TYPE_LONGTEXT, and TYPE_URL -- the same three
types NetBox core's own filter_logic switch covers. TYPE_JSON keeps
its existing icontains-only behavior (no core-NetBox "exact" analog to
mirror).
The comment/docstring volume grew into reviewer-facing rationale
rather than concise code documentation -- restating the same
mechanism (icontains falling outside get_additional_lookups fixed
lookup set) at both the constant definition and its usage site, and a
14-line class docstring duplicating what the individual test method
names and bodies already convey. Trimmed to the load-bearing parts.
@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review - report only on potential concerns.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Claude finished @bctiemann's task in 1m 23s —— View job


PR Review: Closes #639 — Respect filter_logic in generated filtersets

  • Gathering context
  • Analyzing changes
  • Posting review

Three potential concerns:

1. lookup_expr=None passed explicitly relies on django-filters internals

filtersets.py:401:

if field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT:
    extra_kwargs["lookup_expr"] = None

None works because django_filters.Filter.__init__ replaces it with settings.DEFAULT_LOOKUP_EXPR (default "exact"). This is fragile if a NetBox deployment ever customizes DEFAULT_LOOKUP_EXPR. Using "exact" directly would be unambiguous and more defensive. The comment explains the intent, but the implementation still depends on an undocumented-from-the-call-site substitution. Fix this →

2. Disabled fields still participate in the global q search

filtersets.py:447–455:

for field in model.custom_object_type.fields.all():
    if field.type in [TYPE_TEXT, TYPE_LONGTEXT, TYPE_JSON, TYPE_URL, ...]:
        q |= Q(**{f"{field.name}__icontains": value})

There is no filter_logic check in the search() closure. A field marked filter_logic='disabled' suppresses per-field filter registration (correct) but continues to contribute to the global q search — silently. Whether that is intentional needs to be explicit. If disabled should mean "excluded from all filtering including search", this is a gap.

3. No filter_logic test coverage for TYPE_LONGTEXT or TYPE_URL

FILTER_LOGIC_AWARE_TYPES includes all three types, but TextFieldFilterLogicTestCase only exercises TYPE_TEXT. The exact/disabled/loose branching is type-agnostic code so it should behave identically, but a single paragraph test for longtext or URL (even just the disabled case) would eliminate any doubt and guard against a future regression that changes FILTER_LOGIC_AWARE_TYPES without updating tests.


None of these are blocking — concerns 2 and 3 are gaps to document or follow up on rather than bugs in the implemented fix.

@bctiemann
bctiemann requested review from a team and arthanson and removed request for a team August 4, 2026 16:49
@bctiemann bctiemann changed the title Closes #639: Respect filter_logic in generated filtersets Fixes #639: Respect filter_logic in generated filtersets Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Field lookup expressions ignored

1 participant