Skip to content

Closes #36: Add changelog_enabled toggle to CustomObjectType#574

Open
bctiemann wants to merge 13 commits into
featurefrom
36-toggleable-changelog
Open

Closes #36: Add changelog_enabled toggle to CustomObjectType#574
bctiemann wants to merge 13 commits into
featurefrom
36-toggleable-changelog

Conversation

@bctiemann

@bctiemann bctiemann commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Closes: #36

Summary

Adds a changelog_enabled boolean field (default True) to CustomObjectType. When disabled, changes to objects of that type are silently skipped by NetBox's change-logging signal — no ObjectChange rows are written, and the Changelog tab is hidden from the custom object detail page.

How it works

The implementation is confined to two points:

  1. Model fieldCustomObjectType.changelog_enabled (migration 0015, default True).

  2. Generated model overrideget_model() injects a to_objectchange() method that returns None when the flag is off. NetBox's handle_changed_object signal guards on objectchange and objectchange.has_changes before saving, so None is the documented way to suppress a change record — no signal disconnection, no monkey-patching of the signal itself.

Surface area

  • Form: new Options fieldset with the changelog_enabled toggle
  • Serializer: field included in CustomObjectTypeSerializer
  • Table: field available as an optional column
  • COT detail template: status shown with a checkmark
  • CO detail template: Changelog tab is hidden when changelog_enabled=False; direct URL access to the changelog view returns 404

Notes

  • Branching compatibility: if changelog_enabled=False, objects of that type also cannot participate in branching (branching requires change capture). This is consistent with the issue's statement that "this user would be fine with these changes not being branchable."
  • The per-COT toggle was chosen over a global flag because the use case is inherently per-type (some types warrant an audit trail, others don't).

🤖 Generated with Claude Code

@bctiemann bctiemann force-pushed the 36-toggleable-changelog branch from e744d0f to 98426bc Compare June 12, 2026 01:01
@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review and limit response to areas of potential concern.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

Claude finished @bctiemann's task in 2m 56s —— View job


Review: Areas of Potential Concern

  • Read changed files
  • Analyze for concerns

1. Migration is missing help_text and verbose_name

models.py defines:

changelog_enabled = models.BooleanField(
    verbose_name=_('changelog enabled'),
    default=True,
    help_text=_("If disabled, changes to objects…"),
)

But 0015_customobjecttype_changelog_enabled.py has:

field=models.BooleanField(default=True),

Django's Field.deconstruct() includes help_text when non-empty, and verbose_name when it differs from the auto-generated name. Because of lazy-translation proxies, Django may not recognize _('changelog enabled') as equal to the auto-generated 'changelog enabled'. Running makemigrations after merging this will produce a spurious AlterField migration. Recommend regenerating the migration from the final model state before merging. Fix this →


2. Branching side-effect is undocumented in the UI

The PR description notes that changelog_enabled=False also silently disables branching for objects of that type (branching depends on change capture). But this does not appear anywhere in the product:

  • The form field's help_text doesn't mention it
  • The COT detail template only shows a checkmark, no warning
  • There's no form validation warning when this is checked off

An operator disabling changelog for noise-reduction won't know branching is also disabled until they actually try to use it. Consider adding this side-effect to the help_text string, e.g.: "When disabled, objects of this type cannot participate in branching."


3. No test for mid-lifecycle toggle

All three tests create a COT with changelog_enabled fixed at creation time. The realistic user journey — creating a COT with changelog_enabled=True, writing some objects (so the model is cached), then toggling to False and confirming that subsequent saves don't write ObjectChange rows — is not covered.

The cache_timestamp mechanism should handle this correctly (saving the COT bumps cache_timestamp, which forces get_model() to regenerate on the next call), but this is the primary operational path and is currently untested. Fix this →


4. changelog_enabled absent from CustomObjectTypeFilterSet

filtersets.py:259 only exposes id, name, and group_name for COT filtering. Operators can't query the API (or use the list-view filter form) to find all types where changelog is disabled. Given that this is an operational flag, being able to enumerate types with it off seems useful. This is a minor gap, not a blocker.


Minor / Not Concerns

  • The lambda _self, _action: None override is correct. The underscore-prefixed parameters avoid shadowing the outer self (the COT instance); the previous commit note about "fix lambda shadow" confirms this was already caught.
  • Cache invalidation on toggle is handled correctly: CustomObjectType has cache_timestamp = auto_now, so any save() that changes changelog_enabled will cause the next get_model() call to regenerate the model with the new setting.
  • Raising Http404 (rather than 403) on direct URL access to a disabled changelog URL is a reasonable choice — it avoids leaking whether objects exist. The behavior is called out in the PR description.

@bctiemann

Copy link
Copy Markdown
Contributor Author

Addressed items 2, 3, and 4. The migration is fine as-is (makemigrations produces an identical file).

@bctiemann bctiemann requested a review from arthanson June 12, 2026 01:43

@arthanson arthanson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really ugly for branching, I think we might need to say this can only be set on creation and never modified (not sure if that breaks the intent) but if you toggle it mid-way and have items already in a branch I can see all sorts of potential bad side-effects.

Also please add a whole set of tests with branching for this - especially with custom objects having fields linking to and being linked from COT that have object-change disabled and run through the sync/merge/revet lifecycle - they can be added as extra test cases to the co-branching tests. Also make sure polymorphic fields are included as well as those are special case. so object, multi-object and those as polymorphic being in a regular CO linking to a non-object-change CO and the same ones in a non-object-change CO linking to a regular CO.

@bctiemann bctiemann linked an issue Jun 13, 2026 that may be closed by this pull request
@bctiemann

Copy link
Copy Markdown
Contributor Author

The "Changelog enabled" setting now cannot be changed after creation. guarded in the form and serializer.

Also a set of unit tests have been added as recommended. However, note that these tests do not run in CI, so they will not proactively defend against regressions. I've opened #583 to add that pipeline to the CI matrix; it should run in parallel with the existing ones and not take any additional time.

@bctiemann bctiemann requested a review from arthanson June 16, 2026 01:53

@arthanson arthanson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. create a COT with changelog enabled = False
  2. create a CO of that type
  3. delete that CO
  4. crash
During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
  File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/core/handlers/base.py", line 198, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/views/generic/base.py", line 106, in view
    return self.dispatch(request, *args, **kwargs)
           ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ahanson/dev/work/netbox/netbox/netbox/views/generic/base.py", line 26, in dispatch
    return super().dispatch(request, *args, **kwargs)
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ahanson/dev/work/netbox/netbox/utilities/views.py", line 152, in dispatch
    return super().dispatch(request, *args, **kwargs)
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ahanson/dev/work/netbox/netbox/utilities/views.py", line 51, in dispatch
    return super().dispatch(request, *args, **kwargs)
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/views/generic/base.py", line 145, in dispatch
    return handler(request, *args, **kwargs)
  File "/Users/ahanson/dev/work/netbox/netbox/netbox/views/generic/object_views.py", line 482, in post
    obj.delete()
    ~~~~~~~~~~^^
  File "/Users/ahanson/dev/work/netbox-custom-objects/netbox_custom_objects/models.py", line 917, in delete
    return super().delete(*args, **kwargs)
           ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/Users/ahanson/dev/work/netbox/netbox/netbox/models/deletion.py", line 85, in delete
    return collector.delete()
           ~~~~~~~~~~~~~~~~^^
  File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/models/deletion.py", line 462, in delete
    signals.pre_delete.send(
    ~~~~~~~~~~~~~~~~~~~~~~~^
        sender=model,
        ^^^^^^^^^^^^^
    ...<2 lines>...
        origin=self.origin,
        ^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/dispatch/dispatcher.py", line 209, in send
    response = receiver(signal=self, sender=sender, **named)
  File "/Users/ahanson/dev/work/netbox/netbox/core/signals.py", line 201, in handle_deleted_object
    objectchange.user = request.user
    ^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'user' and no __dict__ for setting new attributes

@bctiemann

Copy link
Copy Markdown
Contributor Author

Should be OK now. The fix is in the pre_delete handler, so both UI and API paths should be fixed/exercised by tests.

@bctiemann bctiemann requested a review from arthanson June 16, 2026 21:42

@arthanson arthanson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still having issues:

  1. be in main
  2. create a COT cot1 (turn off ObjectChange), add field text f1
  3. create a CO of that type 'aaa'
  4. create a branch b1 and activate it
  5. create another CO of the cot1 type 'bbb'
  6. go to the list of cot1
  7. switch to main

what you will see is just one item in the list 'aaa'

since ObjectChange is off all items should be created in main branch so you should see two items in the list (both 'aaa' and 'bbb') in main and branch b1.

bctiemann and others added 12 commits June 17, 2026 18:32
Adds a BooleanField 'changelog_enabled' (default True) to CustomObjectType.
When disabled, the generated model's to_objectchange() returns None,
causing NetBox's change-logging signal handler to skip writing ObjectChange
rows — exactly what users need for high-frequency, low-audit-importance
updates (e.g. nightly fleet scans across thousands of objects).

Implementation:
- Model field with migration 0015
- to_objectchange() override injected at get_model() time when disabled
- Form: new 'Options' fieldset exposes the toggle in the UI
- Serializer: field included in CustomObjectTypeSerializer
- Table: field available as an optional column
- Detail template: shows changelog status with checkmark

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

- Template: wraps the Changelog tab link in {% if object.custom_object_type.changelog_enabled %}
  so the tab is absent from the navigation rather than visible-but-empty
- View: raises Http404 when the changelog URL is hit directly for a
  changelog-disabled COT, preventing an empty page via direct URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename lambda params self->_self, action->_action to avoid shadowing
  get_model()'s 'self' (the CustomObjectType instance)
- Add ChangelogEnabledTestCase: verifies to_objectchange() returns None
  directly, that ObjectChange rows are suppressed via the signal, and
  that the default (enabled) path still writes rows
- Add ChangelogEnabledViewTestCase: verifies the changelog URL returns
  404 for a disabled COT, and that the Changelog tab link is absent
  from the object detail template

Migration is unchanged: --check confirms Django correctly omits
verbose_name (matches the auto-generated value) and help_text
(metadata-only, not stored in the schema).

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

- Extend changelog_enabled help_text to note that disabling also prevents
  objects from participating in branching (branching requires change capture)
- Add changelog_enabled to CustomObjectTypeFilterSet so operators can query
  ?changelog_enabled=false via the API or list-view filter
- Add test_mid_lifecycle_toggle_disables_changelog: verifies that toggling
  an existing COT from True→False correctly suppresses ObjectChange rows
  for subsequent saves, exercising the cache_timestamp invalidation path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
connect_signature_invalidation() guarded the branching import with
ImportError, but Django raises RuntimeError (not ImportError) when a
package is importable but absent from INSTALLED_APPS. Use
apps.is_installed() instead, which is the correct check.

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

- changelog_enabled is now locked after COT creation:
  - Form disables the field on edit with a 'cannot be changed' note
  - API serializer rejects updates that change the value
  - Model help_text updated to state the permanence
  This prevents mid-lifecycle branching issues (objects already in a
  branch, partial changelog entries, corrupt branch state on toggle).

- Add ChangelogEnabledImmutabilityTestCase: verifies the form and
  serializer both enforce the lock.

- Add ChangelogEnabledBranchingTestCase: verifies the key cross-COT
  scenarios Arthur identified — objects of a non-changelog COT can't
  be captured in a branch, and object/multiobject/polymorphic field
  references between changelog and non-changelog COTs don't break the
  branch sync/merge lifecycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs in ChangelogEnabledBranchingTestCase caught by running with
netbox-branching enabled:
- merge_strategy='rebase' is not a valid strategy; use 'iterative'
- branch.merge() takes user= and commit= kwargs, not a request object

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
handle_deleted_object in core/signals.py unconditionally calls
objectchange.user = request.user with no None guard, unlike the
save handler which checks 'if objectchange and objectchange.has_changes'.
Returning None from to_objectchange() for delete actions therefore
crashes on CO deletion.

Fix: allow deletes through to the normal ChangeLoggingMixin path so
the delete signal has a valid ObjectChange to work with. Only create
and update actions suppress changelog recording. Deletes are rare and
worth preserving for audit purposes; the high-frequency use case this
flag targets is create/update, not deletion.

Update test to assert None only for create/update and non-None for delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Arthur's scenario: objects created inside a branch context for a
changelog-disabled COT were landing in the branch's PostgreSQL schema
instead of main, so they disappeared when switching back to main.

Root cause: netbox-branching routes writes to a branch-specific PostgreSQL
schema for all ChangeLoggingMixin models. Our supports_branching_resolver
in branching.py already handles M2M through models; extend it to return
False for generated COT models whose CustomObjectType has changelog_enabled=False.
The DB router then routes their reads/writes to the default (main) DB
regardless of the active branch.

- branching.py: supports_branching_resolver returns False when the generated
  model's custom_object_type.changelog_enabled is False
- models.py: update help_text to say 'exempts from branch isolation' rather
  than the vague 'prevents participating in branching'
- test_branching.py: add test_nolog_cot_objects_visible_in_main_when_created_in_branch
  covering Arthur's exact scenario (create in main, create in branch, verify
  both visible in main after deactivating branch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann bctiemann force-pushed the 36-toggleable-changelog branch from 0137c6b to 40eda8e Compare June 17, 2026 22:41
During the rebase onto feature, git checkout --theirs on the live.py conflict
reverted to a pre-fix version. feature already has the correct hoisted
module-level 'from django.apps import apps as django_apps' from PR #573;
restore that version.

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

Copy link
Copy Markdown
Contributor Author

Fixed. Thanks for doing the local testing on this, my custom-objects+branching setup is a bit messed up and I can't be sure I'm covering the right territory.

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.

Toggleable Changelogging

2 participants