Skip to content

Merge main to feature - #666

Closed
bctiemann wants to merge 31 commits into
featurefrom
main
Closed

Merge main to feature#666
bctiemann wants to merge 31 commits into
featurefrom
main

Conversation

@bctiemann

@bctiemann bctiemann commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Merge 'main' into 'feature'.

bctiemann and others added 30 commits July 6, 2026 12:40
…#586)

* Closes #268: Add display_expression for composite custom object names

Adds a Jinja2 display_expression field to CustomObjectType. When set,
CustomObject.__str__() renders it with all field values as context
instead of falling back to the single primary-field display name.

- models.py: display_expression CharField(max_length=500, blank=True)
  on CustomObjectType; CustomObject._render_display_expression() helper
  renders it via a SandboxedEnvironment (security); any rendering error
  silently falls through to the existing primary-field fallback
- migration 0015
- forms.py: Display fieldset with display_expression
- serializers.py: display_expression in CustomObjectTypeSerializer
- customobjecttype.html: shows expression in code block when set
- 5 tests: composite render, missing-field fallback, empty expression,
  rendering error, empty-result fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Move display-related fields into Display fieldset

Group verbose_name, verbose_name_plural, group_name, and display_expression
into a Display fieldset positioned between Name and URL path/slug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Clarify help text

* Add performance note to _render_display_expression docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address agent review: caching, error handling, validation, import form, trailing separator docs

- Move _compile_display_template() after imports (not in the middle of them)
- Cache compiled Jinja2 templates via functools.lru_cache(maxsize=256)
  keyed by expression string; avoids N recompilations on list views
- Move custom_object_type access inside the try block so RelatedObjectDoesNotExist
  and deserialization errors also fall through silently
- Shorten _render_display_expression docstring to one line; move the
  performance/caching note to inline comments
- Add clean_display_expression() to CustomObjectTypeForm: parses the
  expression via SandboxedEnvironment().parse() and surfaces a
  ValidationError with the Jinja2 error message on syntax errors
- Add display_expression (plus verbose_name, verbose_name_plural,
  group_name) to CustomObjectTypeImportForm
- Extend help_text to warn about trailing separators when referenced
  fields are blank, with the recommended {% if %} pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add trailing-separator and form-validation tests; coerce None field values to ''

- _render_display_expression: coerce None return from get_display_value()
  to '' so unset nullable fields render as empty string rather than 'None'
- test_trailing_separator_with_blank_optional_field: documents and tests
  the dangling-separator behaviour vs the {% if %} guard pattern
- DisplayExpressionFormValidationTestCase: 4 tests covering valid expression,
  blank expression, invalid Jinja2 syntax, and unclosed block tag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix ruff E501 long-line violations in test_models.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Use core render_jinja2 util instead of rolling our own

* migrations: renumber display_expression migration to 0017

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use RequestContext in CustomObjectLink.left_page() so render_table can access context.request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: revert render_jinja2 to hand-rolled SandboxedEnvironment; fix migration newline

Avoids footgun where JINJA2_FILTERS custom filters pass form validation
(plain SandboxedEnvironment) but fail silently at render time (render_jinja2
picks up settings filters). Both paths now use the same environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Add branching-aware test configuration and CI coverage for Custom Objects,
including a dedicated branching test suite for sync, merge, revert, and
cross-COT lifecycle scenarios.

Fix branch merge handling for dynamically generated Custom Object models by
preserving dependency graph edges when ContentType.model_class() is not
available, including self-referential multi-object fields.

Create direct M2M target foreign keys as DEFERRABLE INITIALLY DEFERRED and
re-defer them after creation so iterative branch merges can insert through
table rows before all target objects exist in the main schema.

Stabilize the branching test environment by cleaning up branch schemas before
flush, closing/terminating client branch connections, tuning PostgreSQL for
schema-heavy tests, and limiting the branching CI job to the branching test
suite.

Also update CI to test against NetBox's pinned requirements without upgrading
dependencies, split branching and non-branching test steps, and add
compatibility stubs for inherited NetBox view tests.
#620 - avoid loading entire table into memory when opening bulk import/edit/delete pages
…l docs (#623)

* Closes #566: Add missing collectstatic step to install instructions

README.md and docs/installation.md omitted collectstatic, unlike
NetBox core's generic plugin install instructions, which always
include it alongside migrate.

* Build in-app model documentation for CustomObjectType

Even with collectstatic wired into the install instructions, the
"Help" link on CustomObjectType's edit page (docs_url, served at
static/docs/models/netbox_custom_objects/customobjecttype/) 404'd,
since the plugin shipped no source content and no build step for it,
unlike NetBox core's own model docs (built via mkdocs directly into
its Django static tree).

Add docs/models/netbox_custom_objects/customobjecttype.md and a
dedicated mkdocs.models.yml (site_dir points into
netbox_custom_objects/static/docs/models, mirroring NetBox core's
docs_url convention) so this page builds and collects correctly.
Wire the build into the release workflow before packaging, since the
package-data glob in pyproject.toml already includes any static/
files present at build time.

Verified end-to-end: built the wheel locally and confirmed
netbox_custom_objects/static/docs/models/netbox_custom_objects/customobjecttype/index.html
lands at the exact path Django's AppDirectoriesFinder would collect
under STATIC_ROOT.

* Drop redundant leading slash in .gitignore entry

* Address review findings on model-docs build

- Move model doc source from docs/models/ to docs_models/, since the
  former overlapped with the primary mkdocs.yml's default docs_dir,
  causing an unreferenced-page warning on every primary docs build.
- Switch mkdocs.models.yml to the lightweight built-in "mkdocs" theme
  and disable plugins, cutting shipped package size from 2.5MB to
  1.9MB by dropping mkdocs-material's 40-language search bundle
  entirely (not needed for a single-page site).
- Pin mkdocs/mkdocs-material version ranges in release.yaml, guarding
  against the upstream-announced breaking mkdocs-material 2.0 rewrite
  silently breaking a future release build.
- Add a PR-time check to lint-tests.yaml that builds mkdocs.models.yml
  and asserts the expected output file exists, so a broken nav entry
  or config typo is caught on every PR instead of only at release
  time.
- Add trailing newlines to the two new files, matching the rest of
  the docs tree.

Re-verified end-to-end after these changes: wheel build still places
the file at the correct path, and building the primary mkdocs.yml no
longer emits the unreferenced-page warning.

* Address automated review findings on mkdocs pins and release build

- Pin mkdocs/mkdocs-material in pyproject.toml's dev extras to match
  the ranges in release.yaml, so the lint job (pip install .[dev])
  can't silently pass on a mkdocs 2.x or other future release that
  the actual release build would never see.
- Add the same output-verification assertion used in lint-tests.yaml
  to release.yaml, so a build producing output at the wrong location
  fails the release instead of silently publishing a package missing
  the model docs.

The hardcoded blob/main GitHub links in the model doc were also
flagged; left as-is, matching the existing project-wide pattern
(README.md, docs/installation.md already link the same way).
dd a custom_objects namespace and filter for resolving Custom Object
Types by name in device configuration and export templates.

Support dot, bracket, and filter syntax, with per-render caching and an
empty read-only queryset fallback for unknown type names. Log unresolved
names once per process and prevent the filter from querying the database
during Jinja template compilation.

Register the integration through NetBox's jinja_filters resource and
get_jinja_context hook. Older NetBox versions continue to load the
plugin without exposing the new template helpers.

Add unit and integration coverage for ConfigTemplate rendering, unknown
types, repeated lookups, leading-digit names, and compile-time query
prevention.
)

The dynamically generated filterset only registered fields backed by
CustomObjectTypeField records. Since the model primary key is not a
user-defined field, no filter was registered for `id`, causing
`?id=<pk>` to be silently ignored.

Include `id` in `Meta.fields` so django-filter generates NetBox's
standard multi-value numeric filter for the primary key without
affecting the explicitly generated custom field filters.

Add regression coverage to the existing filterset and API test classes.
The filterset test runs across all supported scalar field types, while
the API test covers a Custom Object Type with no fields and verifies
that only the matching object is returned.
…lated field edits (#631)

* Closes #625: Fix schema_id nulled by unrelated edits to a field

CustomObjectTypeFieldForm.Meta.fields = '__all__' pulled schema_id in
as a real, writable form field, but it's never rendered in any
fieldset. The browser therefore never submits a value for it; Django
reads the missing key as None, and ModelForm._post_clean() overwrites
the existing schema_id with that None on every save -- even when the
user only changed an unrelated attribute like label.

Set editable=False on schema_id, matching its existing read_only_fields
treatment in the REST API serializer (CustomObjectTypeFieldSerializer)
and NetBox core's own convention for internal, system-managed model
fields. Django's ModelForm machinery automatically drops non-editable
fields from Meta.fields = '__all__', so it's simply absent from the
form and never touched.

Also excludes deprecated, deprecated_since, and scheduled_removal from
CustomObjectTypeFieldForm: same root cause (never rendered in a
fieldset, silently reset by an unrelated edit) and same fix shape,
but via Meta.exclude on the form rather than editable=False on the
model, since these three ARE meant to be writable through the REST API
(set via the portable-schema import executor) and are not declared as
explicit serializer fields there -- editable=False would have made DRF
silently force them read-only in the API too.

Verified via git-stash-based regression testing: the new test fails
against the pre-fix code with the exact reported symptom (schema_id
becomes None after an unrelated edit), and passes after the fix.

* Add regression test: deprecation fields remain writable via API

Per code review on #631: confirm deprecated/deprecated_since/
scheduled_removal can still be set via the REST API after excluding
them from CustomObjectTypeFieldForm, since editable=False (used for
schema_id) would have made DRF silently force them read-only too.

Verified the test catches this failure mode: temporarily added
editable=False to deprecated and confirmed the test fails with
the exact symptom (PATCH succeeds but the value isn't persisted).

* Hoist import json
…against concurrent readers

create_polymorphic_m2m_table() built and registered a fresh through-model
class and only afterward repointed its "source" FK at the caller's model,
all without holding CustomObjectType._global_lock. A concurrent
get_model(no_cache=True) call -- lock-protected only on its own side --
could land in that window, find the through model already registered, and
repoint "source" at its own (different) model instance instead, leaving the
through's FK and whatever get_model() subsequently caches pointing at two
different classes for the same table. That produced the intermittent
ValueError ("Cannot query 'X': Must be 'TableYModel' instance.") and
RecursionError reported here (recurrence of #477).

Wrapping the build+register+repoint sequence in the same global lock closes
the gap. Added a deterministic regression test that forces a writer thread
(create_polymorphic_m2m_table) and a reader thread (get_model) into the
exact interleaving via a mocked apps.register_model(), rather than relying
on real thread-scheduling luck to land inside the race window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ments

PolymorphicMultiObjectConcurrencyTestCase exercises through-model
registration during polymorphic multiobject field creation (a schema
operation), not deletion logic -- it only lived in test_deletion.py because
the investigation started from the bug's delete-time symptom. Moved it
next to the other schema-creation/registry tests it actually belongs with.

Also trimmed the docstrings and inline comments, which had grown into
multi-paragraph explanations restating the same points -- cut to the
essential why (what's already locked, what isn't, and why the fixed case
times out rather than deadlocking) without re-deriving the whole
investigation inline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tests (main) was failing on 4 list-view query-count assertions, expecting
2 more queries than NetBox's current main branch actually issues -- an
upstream prefetch/permission-check optimization shaved 2 queries off
customobject-simple, customobject-objectfields, customobject-complex, and
customobjecttype's list_objects_with_permission checks since these
baselines were last recorded.

Regenerated via UPDATE_QUERY_COUNTS=1 against a clean checkout of NetBox
main (not the locally cached checkout, which was 33 commits behind and had
a handful of divergent dependency pins). All four keys dropped by exactly
2 queries each, matching the CI failure precisely; no other keys changed.
Re-ran the full plugin suite (1126 tests) against the same environment to
confirm nothing else regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ines

Fixes #649: Refresh query_counts.json baseline for current NetBox main
reader_done.wait(timeout=2) inside ordered_register_model always times
out with the fix applied (R is blocked on _global_lock and can never
signal it), taxing every CI run by a flat 2 seconds. The duration only
bounds an unavoidable wait; correctness doesn't depend on it, since R's
ability to run concurrently is decided by lock state, not by wall-clock
timing. Confirmed via 5 runs each way: cutting it to 0.5s still passes
reliably with the fix and still fails reliably without it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test_forced_registration_interleaving_stays_consistent: assert both
  threads actually completed after join(), rather than letting a
  join() timeout silently leave the result dicts empty and the
  assertions below vacuously pass.
* Cover the delete-confirmation GET (issue #640, step 4): obj.delete()
  realigns each through's "source" FK to type(self) before Django's
  collector runs, which would silently paper over a lingering registry
  mismatch that a plain GET -- the actual reported UI path -- does not
  repair.
* Add a regression through the public field-save path
  (CustomObjectTypeField.objects.create()) with the reported two-type
  Custom Object setup, instead of only ever starting from an
  already-persisted field and calling create_polymorphic_m2m_table()
  directly. A deterministic (mocked apps.register_model()) version of
  this specific scenario was attempted and abandoned after it produced
  a genuine deadlock in testing: two threads targeting the identical
  through table can block each other at the Postgres DDL level while
  also contending for CustomObjectType._global_lock. Real
  thread-scheduling concurrency, exercised via 12 looping readers
  (mirroring the existing single-type test), reaches the same code
  path safely.
…ultiobject-delete-race

# Conflicts:
#	netbox_custom_objects/tests/test_schema_operations.py
create_polymorphic_m2m_table() held _global_lock across both the
build+register+repoint step AND the table-existence probe/DDL. A
concurrent CustomObjectTypeField.save() for the same field also calls
CustomObjectType.clear_model_cache(), which acquires this same lock:
if the lock stayed held across schema_editor.create_model() (an
uncommitted CREATE TABLE inside this save()'s own transaction), a
second thread blocked on the lock -- itself stuck at the Postgres
level waiting on the first thread's uncommitted transaction for the
same physical table -- would prevent the first thread from ever
reaching clear_model_cache() to commit. Neither side could then make
progress.

Scope the lock to just the build+register+repoint step; release it
before the table-existence probe/DDL runs. Confirmed via a new
regression test (two threads double-submitting field creation for
the identical (custom_object_type, name)): hangs against the
previous, wider-scoped lock (reproduced the exact deadlock signature
in pg_stat_activity -- one thread idle-in-transaction waiting on the
lock, the other actively blocked on Postgres waiting for the first's
uncommitted CREATE TABLE), completes in ~1.5s with the fix.
Bump the maximum supported NetBox version to 4.7.x and record it in the
compatibility matrix. No plugin code changes are required: the deferred
search-cache flush error against Custom Object dynamic tables is fixed in
NetBox core, so 4.7 support depends on a NetBox release carrying that fix.
…st docstring

The registration-before-repoint race and its regression test comments were
still citing #640 (the unreproduced report this PR doesn't fix) instead of
#658 (the actual bug this PR fixes and closes). Left the one reference to
#640 that correctly attributes the delete-confirmation-GET test coverage to
that issue's own numbered reproduction steps, which #658 doesn't have.

Also corrected test_forced_registration_interleaving_stays_consistent's
docstring: it described _global_lock as held "for that whole call," which
was true before the lock was narrowed to stop before the table-existence
probe/DDL. Shortened to describe only what the test itself asserts, with a
pointer to #658 for the full analysis.
…ct-delete-race

Closes #658: Harden polymorphic multiobject through-model registration against concurrent creation/reads
…tibility (#662)

Django 6.1 added a keyword-only fetch_mode parameter to Model.from_db().
Forward **kwargs to super().from_db() so the override works under both
Django 6.0.x (no extra kwargs passed) and 6.1+ (fetch_mode forwarded).

Closes: #661
…lk edit forms (#663)

Fields configured with ui_editable=hidden were disabled but still rendered
because the form templates render all grouped fields unconditionally.

Exclude hidden fields and their backing columns from create, edit, and bulk
edit forms while keeping ui_editable=no fields visible and read-only. Reuse
the fetched field definitions and coordinate helpers to avoid duplicate
queries and naming logic.

Preserve hidden non-polymorphic MultiObject fields for internal bookkeeping
so configured defaults are applied when creating an object and existing
relations remain unchanged when editing one.

Add regression coverage for regular, coordinate, read-only, and MultiObject
fields.
@bctiemann bctiemann mentioned this pull request Aug 19, 2026
@bctiemann

Copy link
Copy Markdown
Contributor Author

Superseded by #667 — this PR's head branch is main itself, which isn't safe to push conflict-resolution commits onto. #667 has the same merge (feature ← main) resolved on a proper throwaway branch, plus fixups for issues the merge introduced, with the full suite verified passing.

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.

3 participants