Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
14 changes: 0 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,6 @@ $ ./manage.py migrate
sudo systemctl restart netbox netbox-rq
```

> [!NOTE]
> If you are using NetBox Custom Objects with NetBox Branching, you need to insert the following into your `configuration.py`. See the docs for a full description of how NetBox Custom Objects currently works with NetBox Branching.

```
PLUGINS_CONFIG = {
'netbox_branching': {
'exempt_models': [
'netbox_custom_objects.customobjecttype',
'netbox_custom_objects.customobjecttypefield',
],
},
}
```

## Known Limitations

NetBox Custom Objects is now Generally Available which means you can use it in production and migrations to future versions will work. There are many upcoming features including GraphQL support - the best place to see what's on the way is the [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) list on the GitHub repository.
91 changes: 91 additions & 0 deletions netbox_custom_objects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,89 @@ def _migration_finished(sender, **kwargs):
_migrations_checked = None


def _patch_get_serializer_for_model():
"""
Patch utilities.api.get_serializer_for_model to handle dynamically-generated
custom object models.

The default implementation resolves serializers by import path convention
(e.g. netbox_custom_objects.api.serializers.Table1ModelSerializer). Dynamic
models have no importable serializer at that path, so the call raises
SerializerNotFound. This patch intercepts the lookup for APP_LABEL models and
delegates to get_serializer_class(), which generates the serializer on the fly.
"""
import utilities.api as _api_utils

_original = _api_utils.get_serializer_for_model

def _patched(model, prefix=''):
# Only intercept dynamically-generated custom object models (Table1Model,
# Table2Model, …) identified by their Table{n}Model name pattern.
# CustomObjectType and CustomObjectTypeField live in the same app but
# have importable serializers and must go through the normal path.
if getattr(model, '_meta', None) and model._meta.app_label == APP_LABEL \
and extract_cot_id_from_model_name(model.__name__.lower()) is not None:
from netbox_custom_objects.api.serializers import get_serializer_class
return get_serializer_class(model)
return _original(model, prefix=prefix)

_api_utils.get_serializer_for_model = _patched

# Also patch the reference already imported into extras.events (and anywhere
# else that did `from utilities.api import get_serializer_for_model` before
# our patch ran).
try:
import extras.events as _extras_events
_extras_events.get_serializer_for_model = _patched
except (ImportError, AttributeError):
pass


def _patch_check_object_accessible_in_branch():
"""
Patch check_object_accessible_in_branch to use an existence check instead of
a full SELECT for custom object models.

The original implementation does model.objects.get(pk=object_id) which issues
SELECT * including every custom field column. If a field was renamed in the
branch but the stable db_column is not yet reflected in the model (e.g. due to
a stale cache), this can raise ProgrammingError. For custom objects we only
need to know whether the row exists, so filter(pk=...).exists() is sufficient
and avoids referencing any column other than the primary key.
"""
try:
import netbox_branching.signal_receivers as _sr
from netbox_branching.utilities import deactivate_branch
from netbox_branching.models import ChangeDiff
from core.choices import ObjectChangeActionChoices
from django.contrib.contenttypes.models import ContentType

_original = _sr.check_object_accessible_in_branch

def _patched(branch, model, object_id):
if model._meta.app_label != APP_LABEL:
return _original(branch, model, object_id)

# Check existence in main using only the pk — avoids SELECT on
# renamed columns that may not yet exist in main.
with deactivate_branch():
if model.objects.filter(pk=object_id).exists():
return True

# Not in main — was it created in this branch?
content_type = ContentType.objects.get_for_model(model)
return ChangeDiff.objects.filter(
branch=branch,
object_type=content_type,
object_id=object_id,
action=ObjectChangeActionChoices.ACTION_CREATE,
).exists()

_sr.check_object_accessible_in_branch = _patched
except (ImportError, AttributeError):
pass


def _patch_object_selector_view():
"""
Patch ObjectSelectorView to support dynamically-generated custom object models.
Expand Down Expand Up @@ -183,6 +266,14 @@ def ready(self):
# Patch ObjectSelectorView to support dynamically-generated custom object models
_patch_object_selector_view()

# Patch get_serializer_for_model so event rules, job serializers, etc. can
# resolve serializers for dynamically-generated custom object models.
_patch_get_serializer_for_model()

# Patch check_object_accessible_in_branch to use pk-only existence check,
# avoiding SELECT * which references renamed columns that may not exist in main.
_patch_check_object_accessible_in_branch()

# Suppress warnings about database calls during app initialization
with warnings.catch_warnings():
warnings.filterwarnings(
Expand Down
10 changes: 0 additions & 10 deletions netbox_custom_objects/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,8 @@ class ETagMixin: # pragma: no cover – NetBox < 4.6 shim

from netbox_custom_objects.filtersets import get_filterset_class
from netbox_custom_objects.models import CustomObjectType, CustomObjectTypeField
from netbox_custom_objects.utilities import is_in_branch

from . import serializers

# Constants
BRANCH_ACTIVE_ERROR_MESSAGE = _("Please switch to the main branch to perform this operation.")


class RootView(APIRootView):
def get_view_name(self):
Expand Down Expand Up @@ -77,14 +72,9 @@ def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)

def create(self, request, *args, **kwargs):
if is_in_branch():
raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE)
return super().create(request, *args, **kwargs)

def update(self, request, *args, **kwargs):
if is_in_branch():
raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE)

# Replicate DRF's UpdateModelMixin.update() so we can snapshot the instance
# before the serializer is constructed. Calling super().update() would invoke
# get_object() a second time and return a fresh, un-snapshotted instance.
Expand Down
5 changes: 3 additions & 2 deletions netbox_custom_objects/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,11 +987,12 @@ def after_model_generation(self, instance, model, field_name):
target_field.remote_field.model = to_model
target_field.related_model = to_model

def create_m2m_table(self, instance, model, field_name):
def create_m2m_table(self, instance, model, field_name, schema_conn=None):
"""
Creates the actual M2M table after models are fully generated
"""
from django.db import connection
from django.db import connection as default_connection
connection = schema_conn if schema_conn is not None else default_connection

# Get the field instance
field = model._meta.get_field(field_name)
Expand Down
3 changes: 3 additions & 0 deletions netbox_custom_objects/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.contrib.postgres.fields import ArrayField
from django.db.models import JSONField, Q
from django.utils.dateparse import parse_date, parse_datetime
from django.utils.timezone import make_aware, is_aware

from extras.choices import CustomFieldTypeChoices
from netbox.filtersets import NetBoxModelFilterSet
Expand Down Expand Up @@ -89,6 +90,8 @@ def search(self, queryset, name, value):
elif field.type == CustomFieldTypeChoices.TYPE_DATETIME:
parsed = parse_datetime(value)
if parsed is not None:
if not is_aware(parsed):
parsed = make_aware(parsed)
q |= Q(**{f"{field.name}__exact": parsed})
if not q:
return queryset.none()
Expand Down
1 change: 1 addition & 0 deletions netbox_custom_objects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ class CustomObjectTypeFieldForm(CustomFieldForm):
class Meta:
model = CustomObjectTypeField
fields = "__all__"
exclude = ('db_column',)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Drop DEFERRABLE INITIALLY DEFERRED from FK constraints on custom object tables.

Prior to this migration, _ensure_field_fk_constraint() created FK constraints
with DEFERRABLE INITIALLY DEFERRED. That attribute causes PostgreSQL to queue
trigger events that block subsequent ALTER TABLE calls (e.g. remove_field during
a branch revert), raising "cannot ALTER TABLE because it has pending trigger
events".

This migration finds all DEFERRABLE FK constraints on tables whose names start
with "custom_objects_" and recreates them as non-DEFERRABLE with ON DELETE
CASCADE, matching the behaviour of the updated _ensure_field_fk_constraint().
"""

from django.db import migrations


def fix_deferrable_fk_constraints(apps, schema_editor):
"""
Re-create any DEFERRABLE FK constraints on custom object tables as
non-DEFERRABLE. Uses information_schema so no Django model loading
is required — safe to run during the migration pass even though dynamic
models are not yet registered.
"""
with schema_editor.connection.cursor() as cursor:
# Find all DEFERRABLE FK constraints on custom_objects_* tables.
cursor.execute("""
SELECT
tc.table_name,
tc.constraint_name,
kcu.column_name,
ccu.table_name AS foreign_table_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
JOIN information_schema.referential_constraints AS rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name LIKE 'custom_objects\\_%%'
AND tc.is_deferrable = 'YES'
""")
rows = cursor.fetchall()

for table_name, constraint_name, column_name, foreign_table in rows:
new_constraint_name = f'{table_name}_{column_name}_fk_cascade'
cursor.execute(
f'ALTER TABLE "{table_name}" DROP CONSTRAINT "{constraint_name}"'
)
cursor.execute(f"""
ALTER TABLE "{table_name}"
ADD CONSTRAINT "{new_constraint_name}"
FOREIGN KEY ("{column_name}")
REFERENCES "{foreign_table}" ("id")
ON DELETE CASCADE
""")


class Migration(migrations.Migration):

dependencies = [
('netbox_custom_objects', '0006_customobjecttypefield_related_name_and_more'),
]

operations = [
migrations.RunPython(
fix_deferrable_fk_constraints,
migrations.RunPython.noop,
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Add ``db_column`` to CustomObjectTypeField and back-fill it from ``name``.

``db_column`` is frozen at field creation time so that subsequent renames are
pure metadata operations — the physical database column name never changes.
This prevents cross-schema column-name mismatches when a field is renamed in
one schema (e.g. a branch) and the model is then used to query a different
schema (e.g. main) that still has the original column name.

The data migration sets ``db_column = name`` for all existing fields so that
``effective_db_column`` returns the same value as before the migration.
"""

from django.db import migrations, models


def backfill_db_column(apps, schema_editor):
"""Set db_column = name for all existing CustomObjectTypeField rows."""
CustomObjectTypeField = apps.get_model('netbox_custom_objects', 'CustomObjectTypeField')
CustomObjectTypeField.objects.filter(db_column='').update(db_column=models.F('name'))


class Migration(migrations.Migration):

dependencies = [
('netbox_custom_objects', '0007_fix_object_field_fk_deferrable'),
]

operations = [
migrations.AddField(
model_name='customobjecttypefield',
name='db_column',
field=models.CharField(
blank=True,
default='',
help_text=(
'Physical database column name. Set once at creation and never changed, '
'so renames are pure metadata changes that do not require DDL.'
),
max_length=50,
verbose_name='database column',
),
preserve_default=False,
),
migrations.RunPython(
backfill_db_column,
migrations.RunPython.noop,
),
]
Loading
Loading