Closes #36: Add changelog_enabled toggle to CustomObjectType#574
Closes #36: Add changelog_enabled toggle to CustomObjectType#574bctiemann wants to merge 17 commits into
Conversation
e744d0f to
98426bc
Compare
|
@claude /review and limit response to areas of potential concern. |
|
Claude finished @bctiemann's task in 2m 56s —— View job Review: Areas of Potential Concern
1. Migration is missing
|
|
Addressed items 2, 3, and 4. The migration is fine as-is ( |
arthanson
left a comment
There was a problem hiding this comment.
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.
|
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. |
arthanson
left a comment
There was a problem hiding this comment.
- create a COT with changelog enabled = False
- create a CO of that type
- delete that CO
- 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
|
Should be OK now. The fix is in the |
arthanson
left a comment
There was a problem hiding this comment.
still having issues:
- be in main
- create a COT cot1 (turn off ObjectChange), add field text f1
- create a CO of that type 'aaa'
- create a branch b1 and activate it
- create another CO of the cot1 type 'bbb'
- go to the list of cot1
- 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.
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>
…LLED_APPS" This reverts commit c9867db.
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>
0137c6b to
40eda8e
Compare
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>
|
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. |
arthanson
left a comment
There was a problem hiding this comment.
- create a branch b1 and activate it.
- create a COT and set changelog enabled to False
- save the COT
- Get Error
I think the save on the COT needs to detect if the enabled_objectchange is False and if so save to main instead of the branch...
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 87, in get
**self.get_extra_context(request, instance),
~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox-custom-objects/netbox_custom_objects/views.py", line 335, in get_extra_context
"table": self.get_table(self.queryset, request),
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox-custom-objects/netbox_custom_objects/views.py", line 317, in get_table
return super().get_table(data, request, bulk_actions=False)
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox-custom-objects/netbox_custom_objects/views.py", line 293, in get_table
return super().get_table(data, request, bulk_actions=bulk_actions)
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/netbox/netbox/views/generic/mixins.py", line 106, in get_table
table.configure(request)
~~~~~~~~~~~~~~~^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/netbox/netbox/tables/tables.py", line 296, in configure
super().configure(request)
~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/netbox/netbox/tables/tables.py", line 216, in configure
tables.RequestConfig(request, paginate).configure(self)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django_tables2/config.py", line 63, in configure
table.paginate(**kwargs)
~~~~~~~~~~~~~~^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django_tables2/tables.py", line 570, in paginate
self.page = self.paginator.page(page)
~~~~~~~~~~~~~~~~~~~^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/core/paginator.py", line 177, in page
number = self.validate_number(number)
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/core/paginator.py", line 160, in validate_number
return self._validate_number(number, self.num_pages)
^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/utils/functional.py", line 47, in __get__
res = instance.__dict__[self.name] = self.func(instance)
~~~~~~~~~^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/core/paginator.py", line 195, in num_pages
if self.count == 0 and not self.allow_empty_first_page:
^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/utils/functional.py", line 47, in __get__
res = instance.__dict__[self.name] = self.func(instance)
~~~~~~~~~^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/core/paginator.py", line 190, in count
return len(self.object_list)
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django_tables2/rows.py", line 304, in __len__
length = len(self.data)
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django_tables2/data.py", line 142, in __len__
self._length = self.data.count()
~~~~~~~~~~~~~~~^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/models/query.py", line 610, in count
return self.query.get_count(using=self.db)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/models/sql/query.py", line 656, in get_count
return obj.get_aggregation(using, {"__count": Count("*")})["__count"]
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/models/sql/query.py", line 638, in get_aggregation
result = compiler.execute_sql(SINGLE)
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/models/sql/compiler.py", line 1624, in execute_sql
cursor.execute(sql, params)
~~~~~~~~~~~~~~^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/debug_toolbar/panels/sql/tracking.py", line 243, in execute
return self._record(super().execute, sql, params)
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/debug_toolbar/panels/sql/tracking.py", line 164, in _record
return method(sql, params)
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/backends/utils.py", line 122, in execute
return super().execute(sql, params)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/backends/utils.py", line 79, in execute
return self._execute_with_wrappers(
~~~~~~~~~~~~~~~~~~~~~~~~~~~^
sql, params, many=False, executor=self._execute
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/backends/utils.py", line 92, in _execute_with_wrappers
return executor(sql, params, many, context)
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/backends/utils.py", line 100, in _execute
with self.db.wrap_database_errors:
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/utils.py", line 94, in __exit__
raise dj_exc_value.with_traceback(traceback) from exc_value
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/django/db/backends/utils.py", line 105, in _execute
return self.cursor.execute(sql, params)
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
File "/Users/ahanson/dev/work/netbox/venv/lib/python3.14/site-packages/psycopg/cursor.py", line 117, in execute
raise ex.with_traceback(None)
django.db.utils.ProgrammingError: relation "custom_objects_1" does not exist
LINE 1: SELECT COUNT(*) AS "__count" FROM "custom_objects_1"
Grant the API test user view permissions for related objects referenced in write payloads, including devices, tags, and target custom object types. This keeps API writes compatible with NetBox's permission-filtered related-object resolution and prevents HTTP 400 failures in the tests. Fixes #592
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
taggit's TaggableManager is not detected by DRF's model_meta.get_field_info() as a standard M2M relation. Without explicit handling, 'tags' would remain in validated_data and be passed to Model._default_manager.create(), where Django's __init__ sets it as a plain instance attribute (shadowing the manager). This gave the illusion of success — the POST/PATCH response included the tags — but the TaggableManager's DB-backed storage was never written, so a subsequent GET returned empty tags. The fix mirrors TaggableModelSerializer._save_tags(): pop 'tags' from validated_data before model creation/update, then write via instance.tags.set([t.name for t in tags]) after the instance exists. instance._tags is also set for NetBox's change-logging machinery. Adds two regression tests (create and PATCH) that verify tags are present on a fresh DB fetch rather than just checking the response payload.
There was a problem hiding this comment.
still not working, same scenario as before:
- create and activate a branch
- create a COT in the branch with Changelog Enabled False
- save
Now it doesn't crash but gets an error toast "Operation failed due to object-level permissions violation"
I reset the database and started from clean to double-check. Also leads to a weird condition that I think needs to be thought through - it creates the COT in main but you are in a branch, if you then go to the list of COT you get an empty list, this is because some of the items are in main and some are in the branch which is not something we do in branching currently (all models are either branch aware or not) so there is a really confusing disconnect.
For this to work I think you would need to do the same thing that branch provisioning does and copy over the record to the CO table in the branch, actually all branch tables - which could be very messy. -or- it would be something like deletion where you create a branch then delete something in main. You would need to detect it and show a message, then sync would need to copy it over, but again this is a weird condition that branching doesn't support - a table is either in a branch or not.
Although something probably useful for users, I think it might need to be tabled as there are a lot of edge conditions for things branching doesn't really support, might cause us a lot of headaches.
Closes: #36
Summary
Adds a
changelog_enabledboolean field (defaultTrue) toCustomObjectType. When disabled, changes to objects of that type are silently skipped by NetBox's change-logging signal — noObjectChangerows are written, and the Changelog tab is hidden from the custom object detail page.How it works
The implementation is confined to two points:
Model field —
CustomObjectType.changelog_enabled(migration 0015, defaultTrue).Generated model override —
get_model()injects ato_objectchange()method that returnsNonewhen the flag is off. NetBox'shandle_changed_objectsignal guards onobjectchange and objectchange.has_changesbefore saving, soNoneis the documented way to suppress a change record — no signal disconnection, no monkey-patching of the signal itself.Surface area
changelog_enabledtoggleCustomObjectTypeSerializerchangelog_enabled=False; direct URL access to the changelog view returns 404Notes
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."🤖 Generated with Claude Code