Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions netbox_custom_objects/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ class Meta:
"slug",
"version",
"group_name",
"changelog_enabled",
"description",
"tags",
"created",
Expand All @@ -343,6 +344,15 @@ class Meta:
read_only_fields = ("schema_document",)
brief_fields = ("id", "url", "name", "slug", "description")

def validate(self, data):
# changelog_enabled is locked after creation.
if self.instance and self.instance.pk:
if 'changelog_enabled' in data and data['changelog_enabled'] != self.instance.changelog_enabled:
raise ValidationError({
'changelog_enabled': _("Cannot be changed after creation.")
})
return super().validate(data)

def get_table_model_name(self, obj):
return obj.get_table_model_name(obj.id)

Expand Down Expand Up @@ -542,6 +552,13 @@ def get__context(self, obj):
def create(self, validated_data):
ModelClass = self.Meta.model

# taggit's TaggableManager is not detected by model_meta.get_field_info() as a
# standard M2M relation. If left in validated_data it gets passed to
# Model._default_manager.create(), where Django's __init__ sets it as a plain
# instance attribute (shadowing the manager), giving the illusion of success but
# never persisting to the DB. Pop it here and save via taggit's own API.
tags = validated_data.pop('tags', None)

info = model_meta.get_field_info(ModelClass)
many_to_many = {}
for field_name, relation_info in info.relations.items():
Expand All @@ -562,6 +579,12 @@ def create(self, validated_data):

instance = ModelClass._default_manager.create(**validated_data)

if tags is not None:
instance._tags = tags
instance.tags.set([t.name for t in tags])
else:
instance._tags = []

if many_to_many:
for field_name, value in many_to_many.items():
field = getattr(instance, field_name)
Expand All @@ -580,6 +603,11 @@ def create(self, validated_data):

# Stock DRF update() with custom field.set() for M2M
def update(self, instance, validated_data):
# Pop tags before the setattr loop — taggit's manager has no __set__, so
# leaving tags in validated_data would shadow the manager with a plain list.
tags = validated_data.pop('tags', None)
instance._tags = tags or []

info = model_meta.get_field_info(instance)

# Pop polymorphic GFK fields
Expand All @@ -606,6 +634,10 @@ def update(self, instance, validated_data):

instance.save()

if tags is not None:
instance._tags = tags
instance.tags.set([t.name for t in tags])

for attr, value in m2m_fields:
field = getattr(instance, attr)
field.set(value, clear=True)
Expand Down
27 changes: 22 additions & 5 deletions netbox_custom_objects/branching.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,36 @@


def supports_branching_resolver(model):
"""Mark CustomObject M2M through models as branchable.
"""Branching support resolver for Custom Object models.

Through models are plain ``models.Model`` subclasses (no ChangeLoggingMixin),
so the default heuristic would route their queries to main even inside a
branch — and the FK to the parent CO would resolve against main's rows.
Returning ``True`` pulls them into the branch connection routing.
Two cases handled:

1. **M2M through models** — plain ``models.Model`` subclasses with no
``ChangeLoggingMixin``, so the default heuristic would route their
queries to main even inside a branch. Return ``True`` to pull them
into branch routing (their parent CO FK must resolve within the branch).

2. **Non-changelog COT models** — when ``changelog_enabled=False`` on the
parent ``CustomObjectType``, the model has no change tracking and must
not be isolated to a branch schema. Objects created in a branch context
should land in main (always visible regardless of active branch), matching
the high-frequency / non-audited use case this flag is designed for.
Return ``False`` to route all reads/writes to the default DB.
"""
meta = getattr(model, '_meta', None)
if meta is None or meta.app_label != 'netbox_custom_objects':
return None
name = meta.model_name or ''

# M2M through models: opt in to branching.
if name.startswith('through_custom_objects_'):
return True

# Generated COT models: opt out of branching when changelog is disabled.
cot = getattr(model, 'custom_object_type', None)
if cot is not None and not getattr(cot, 'changelog_enabled', True):
return False

return None


Expand Down
1 change: 1 addition & 0 deletions netbox_custom_objects/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ class Meta:
"id",
"name",
"group_name",
"changelog_enabled",
)


Expand Down
14 changes: 13 additions & 1 deletion netbox_custom_objects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,28 @@ class CustomObjectTypeForm(NetBoxModelForm):
"name", "verbose_name", "verbose_name_plural", "slug",
"version", "description", "group_name", "tags",
),
FieldSet("changelog_enabled", name=_("Options")),
)
comments = CommentField()

class Meta:
model = CustomObjectType
fields = (
"name", "verbose_name", "verbose_name_plural", "slug", "version", "description",
"group_name", "comments", "tags",
"group_name", "changelog_enabled", "comments", "tags",
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
# changelog_enabled is locked after creation to prevent mid-lifecycle
# branching inconsistencies (objects already in a branch, partial
# changelog entries, etc.).
self.fields['changelog_enabled'].disabled = True
self.fields['changelog_enabled'].help_text = _(
"Cannot be changed after creation."
)


class CustomObjectTypeBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-06-12 01:23

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('netbox_custom_objects', '0014_fix_mixed_case_field_names'),
]

operations = [
migrations.AddField(
model_name='customobjecttype',
name='changelog_enabled',
field=models.BooleanField(default=True),
),
]
38 changes: 37 additions & 1 deletion netbox_custom_objects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,17 @@ class CustomObjectType(NetBoxModel):
blank=True,
help_text=_("Used to group similar custom object types in the navigation menu")
)
changelog_enabled = models.BooleanField(
verbose_name=_('changelog enabled'),
default=True,
help_text=_(
"If disabled, changes to objects of this type will not be recorded in the changelog. "
"Useful for high-frequency updates where audit history is not required. "
"Note: disabling changelog also exempts objects of this type from branch isolation — "
"they are always written to the main database regardless of the active branch. "
"This setting cannot be changed after the Custom Object Type is created."
),
)
schema_document = models.JSONField(
blank=True,
null=True,
Expand Down Expand Up @@ -1543,6 +1554,22 @@ def get_model(
"custom_object_type_id": self.id,
}

# If changelog is disabled for this COT, override to_objectchange() so
# that NetBox's change-logging signal skips writing ObjectChange rows
# for create/update operations.
#
# Delete is intentionally allowed through: core/signals.handle_deleted_object
# calls objectchange.user = ... unconditionally (no None guard), so returning
# None for deletes would crash on deletion. Deletes are also rare and worth
# preserving for audit purposes. The high-frequency use case this flag targets
# is create/update (e.g. nightly fleet scans), not deletion.
if not self.changelog_enabled:
def _no_changelog_to_objectchange(_self, _action):
if _action == ObjectChangeActionChoices.ACTION_DELETE:
return ChangeLoggingMixin.to_objectchange(_self, _action)
return None
attrs["to_objectchange"] = _no_changelog_to_objectchange

# Pass the generating models set to field generation
fields = []
field_attrs = self._fetch_and_generate_field_attrs(
Expand Down Expand Up @@ -1776,7 +1803,10 @@ def create_model(self):
self.object_type.public = True
self.object_type.save()

with _get_schema_connection().schema_editor() as schema_editor:
# Non-changelog COTs bypass branch isolation; their table must live in main
# so that queries (which always route to main for these models) can find it.
schema_conn = connection if not self.changelog_enabled else _get_schema_connection()
with schema_conn.schema_editor() as schema_editor:
schema_editor.create_model(model)

self._store_base_column_snapshot(model)
Expand Down Expand Up @@ -1845,6 +1875,12 @@ def clean_fields(self, exclude=None):
def save(self, *args, **kwargs):
needs_db_create = self._state.adding

# Non-changelog COTs are paired with CO tables in main (COs bypass branch
# isolation). Route the COT record to main as well so the two are consistent.
if not self.changelog_enabled and 'using' not in kwargs:
if _get_schema_connection() is not connection:
kwargs['using'] = 'default'

super().save(*args, **kwargs)

if needs_db_create:
Expand Down
1 change: 1 addition & 0 deletions netbox_custom_objects/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Meta(NetBoxTable.Meta):
"verbose_name_plural",
"slug",
'description',
"changelog_enabled",
'comments',
'tags',
"created",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@
<li class="nav-item">
<a class="nav-link{% if tab == 'journal' %} active{% endif %}" href="{% url 'plugins:netbox_custom_objects:customobject_journal' object.custom_object_type.slug object.pk %}">{% trans "Journal" %}</a>
</li>
{% if object.custom_object_type.changelog_enabled %}
<li class="nav-item">
<a class="nav-link{% if tab == 'changelog' %} active{% endif %}" href="{% url 'plugins:netbox_custom_objects:customobject_changelog' object.custom_object_type.slug object.pk %}">{% trans "Changelog" %}</a>
</li>
{% endif %}
</ul>
{% endblock tabs %}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ <h5 class="card-header">{% trans "Custom Object Type" %}</h5>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Changelog" %}</th>
<td>{% checkmark object.changelog_enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Last activity" %}</th>
<td>
Expand Down
81 changes: 79 additions & 2 deletions netbox_custom_objects/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.test import TestCase, RequestFactory
from django.urls import reverse

from utilities.testing import create_test_user
from utilities.testing import TestCase as NetBoxTestCase, create_test_user
from rest_framework import status
from rest_framework.test import APIClient

Expand All @@ -13,6 +13,7 @@
from .base import CustomObjectsTestCase, create_token
from core.models import ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Site
from extras.models import Tag
from users.models import ObjectPermission
from virtualization.models import Cluster, ClusterType

Expand Down Expand Up @@ -98,7 +99,7 @@ def test_delete_object_without_permission(self):
self.assertHttpStatus(response, 403)


class CustomObjectTest(CustomObjectsTestCase, CustomObjectAPITestCaseMixin, TestCase):
class CustomObjectTest(CustomObjectsTestCase, CustomObjectAPITestCaseMixin, NetBoxTestCase):
model = None # Will be set in setUpTestData
bulk_update_data = {
'test_field': 'Updated test field',
Expand Down Expand Up @@ -371,6 +372,7 @@ def test_create_with_nested_serializers(self):
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
self.add_permissions('dcim.view_device')

devices = Device.objects.all()

Expand Down Expand Up @@ -398,6 +400,80 @@ def test_create_with_nested_serializers(self):
set(data['devices']),
)

def test_create_with_tags_persists_to_db(self):
"""Regression #371: tags submitted on POST must be saved to the DB, not just echoed."""
self._add_permission('add', 'Create with tags perm')
self.add_permissions('extras.view_tag')
tag = Tag.objects.get_or_create(name='api-create-tag', slug='api-create-tag')[0]

data = {
'test_field': 'Tagged Object',
'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug, 'color': tag.color}],
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertIn('tags', response.data)
self.assertTrue(len(response.data['tags']) > 0, 'Response should include the submitted tag')

# Fetch fresh from the DB — the critical assertion that caught #371
instance = self._get_queryset().get(pk=response.data['id'])
self.assertIn(tag.name, list(instance.tags.names()), 'Tag must be persisted to the DB')

def test_patch_with_tags_persists_to_db(self):
"""Regression #371: tags submitted on PATCH must be saved to the DB, not just echoed."""
self._add_permission('view', 'View perm')
self._add_permission('change', 'Patch with tags perm')
self.add_permissions('extras.view_tag')
tag = Tag.objects.get_or_create(name='api-patch-tag', slug='api-patch-tag')[0]

instance = self._get_queryset().first()
self.assertEqual(list(instance.tags.names()), [], 'Instance should start with no tags')

data = {'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug, 'color': tag.color}]}
response = self.client.patch(self._get_detail_url(instance), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)

instance.refresh_from_db()
self.assertIn(tag.name, list(instance.tags.names()), 'Tag must be persisted to the DB after PATCH')

def test_patch_with_empty_tags_clears_existing(self):
"""PATCH with tags=[] must remove all existing tags from the DB."""
self._add_permission('view', 'View perm')
self._add_permission('change', 'Patch clear tags perm')
tag = Tag.objects.get_or_create(name='api-clear-tag', slug='api-clear-tag')[0]

instance = self._get_queryset().first()
instance.tags.add(tag.name)
self.assertIn(tag.name, list(instance.tags.names()), 'Pre-condition: tag should be set')

response = self.client.patch(self._get_detail_url(instance), {'tags': []}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)

instance.refresh_from_db()
self.assertEqual(list(instance.tags.names()), [], 'All tags must be cleared after PATCH with tags=[]')

def test_patch_without_tags_preserves_existing(self):
"""PATCH that omits the tags key entirely must leave existing tags unchanged."""
self._add_permission('view', 'View perm')
self._add_permission('change', 'Patch preserve tags perm')
tag = Tag.objects.get_or_create(name='api-preserve-tag', slug='api-preserve-tag')[0]

instance = self._get_queryset().first()
instance.tags.add(tag.name)
self.assertIn(tag.name, list(instance.tags.names()), 'Pre-condition: tag should be set')

response = self.client.patch(
self._get_detail_url(instance), {'test_field': 'updated'}, format='json', **self.header
)
self.assertHttpStatus(response, status.HTTP_200_OK)

instance.refresh_from_db()
self.assertIn(
tag.name,
list(instance.tags.names()),
'Existing tags must be preserved when tags not in PATCH payload',
)


class LinkedObjectsAPITest(CustomObjectsTestCase, TestCase):
"""
Expand Down Expand Up @@ -1503,6 +1579,7 @@ def _add_perm(self, action, model):
def test_patch_updates_cross_cot_m2m_field(self):
"""#443 – PATCH with a list of target PKs must update the M2M field."""
self._add_perm('change', self.model_source)
self._add_perm('view', self.model_target)

# Confirm initial state.
self.assertSetEqual(
Expand Down
Loading