From 79ad9c630bccfd9cbc027f1079fcda4158676d4c Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 19 Aug 2025 10:03:33 -0700 Subject: [PATCH 01/10] 161 fix multi object field --- netbox_custom_objects/__init__.py | 87 ++++++++----------------------- 1 file changed, 22 insertions(+), 65 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 4bcdcbc..dc4cdc1 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -18,36 +18,6 @@ def is_running_migration(): return False -def is_in_clear_cache(): - """ - Check if the code is currently being called from Django's clear_cache() method. - - TODO: This is fairly ugly, but in models.CustomObjectType.get_model() we call - meta = type() which calls clear_cache on the model which causes a call to - get_models() which in-turn calls get_model and therefore recurses. - - This catches the specific case of a recursive call to get_models() from - clear_cache() which is the only case we care about, so should be relatively - safe. An alternative should be found for this. - """ - import inspect - - frame = inspect.currentframe() - try: - # Walk up the call stack to see if we're being called from clear_cache - while frame: - if ( - frame.f_code.co_name == "clear_cache" - and "django/apps/registry.py" in frame.f_code.co_filename - ): - return True - frame = frame.f_back - return False - finally: - # Clean up the frame reference - del frame - - def check_custom_object_type_table_exists(): """ Check if the CustomObjectType table exists in the database. @@ -73,13 +43,12 @@ class CustomObjectsPluginConfig(PluginConfig): name = "netbox_custom_objects" verbose_name = "Custom Objects" description = "A plugin to manage custom objects in NetBox" - version = "0.2.0" + version = "0.1.0" base_url = "custom-objects" - min_version = "4.4.0" + min_version = "4.2.0" default_settings = {} required_settings = [] template_extensions = "template_content.template_extensions" - _in_get_models = False # Recursion guard def get_model(self, model_name, require_ready=True): try: @@ -115,45 +84,33 @@ def get_model(self, model_name, require_ready=True): def get_models(self, include_auto_created=False, include_swapped=False): """Return all models for this plugin, including custom object type models.""" - # Get the regular Django models first for model in super().get_models(include_auto_created, include_swapped): yield model - # Prevent recursion - if self._in_get_models and is_in_clear_cache(): - # Skip dynamic model creation if we're in a recursive get_models call - return + # Suppress warnings about database calls during model loading + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=RuntimeWarning, message=".*database.*" + ) + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*database.*" + ) - self._in_get_models = True - try: - # Suppress warnings about database calls during model loading - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=RuntimeWarning, message=".*database.*" - ) - warnings.filterwarnings( - "ignore", category=UserWarning, message=".*database.*" - ) - - # Skip custom object type model loading if running during migration - if ( - is_running_migration() - or not check_custom_object_type_table_exists() - ): - return - - # Add custom object type models - from .models import CustomObjectType - - custom_object_types = CustomObjectType.objects.all() - for custom_type in custom_object_types: - model = custom_type.get_model() + # Skip custom object type model loading if running during migration + if is_running_migration() or not check_custom_object_type_table_exists(): + return + + # Add custom object type models + from .models import CustomObjectType + + custom_object_types = CustomObjectType.objects.all() + for custom_type in custom_object_types: + # Only yield already cached models during discovery + if CustomObjectType.is_model_cached(custom_type.id): + model = CustomObjectType.get_cached_model(custom_type.id) if model: yield model - finally: - # Clean up the recursion guard - self._in_get_models = False config = CustomObjectsPluginConfig From a841e1d0038d49eace2400cd80a36a9bd8746350 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 19 Aug 2025 10:04:51 -0700 Subject: [PATCH 02/10] 161 fix multi object field --- netbox_custom_objects/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index dc4cdc1..42fb01b 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -43,9 +43,9 @@ class CustomObjectsPluginConfig(PluginConfig): name = "netbox_custom_objects" verbose_name = "Custom Objects" description = "A plugin to manage custom objects in NetBox" - version = "0.1.0" + version = "0.2.0" base_url = "custom-objects" - min_version = "4.2.0" + min_version = "4.4.0" default_settings = {} required_settings = [] template_extensions = "template_content.template_extensions" From 0f477a4296bbfe2545834de9f18d0f2e90fdab85 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 19 Aug 2025 13:44:23 -0700 Subject: [PATCH 03/10] Check if unique flag raise validation error if non unique data --- netbox_custom_objects/models.py | 58 ++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 9ec2d4c..11e1711 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -12,7 +12,7 @@ # from django.contrib.contenttypes.management import create_contenttypes from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator, ValidationError -from django.db import connection, models +from django.db import connection, models, IntegrityError from django.db.models import Q from django.db.models.functions import Lower from django.db.models.signals import pre_delete @@ -52,6 +52,13 @@ from netbox_custom_objects.constants import APP_LABEL from netbox_custom_objects.field_types import FIELD_TYPE_CLASS + +class UniquenessConstraintTestError(Exception): + """Custom exception used to signal successful uniqueness constraint test.""" + + pass + + USER_TABLE_DATABASE_NAME_PREFIX = "custom_objects_" @@ -310,7 +317,10 @@ def _fetch_and_generate_field_attrs( for field in fields: field_type = FIELD_TYPE_CLASS[field.type]() if skip_object_fields: - if field.type in [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]: + if field.type in [ + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ]: continue field_name = field.name @@ -453,7 +463,9 @@ def get_model( "custom_object_type_id": self.id, } - field_attrs = self._fetch_and_generate_field_attrs(fields, skip_object_fields=skip_object_fields) + field_attrs = self._fetch_and_generate_field_attrs( + fields, skip_object_fields=skip_object_fields + ) attrs.update(**field_attrs) @@ -587,7 +599,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode name = models.CharField( verbose_name=_("name"), max_length=50, - help_text=_("Internal field name, e.g. \"vendor_label\""), + help_text=_('Internal field name, e.g. "vendor_label"'), validators=( RegexValidator( regex=r"^[a-z0-9_]+$", @@ -616,7 +628,9 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode verbose_name=_("group name"), max_length=50, blank=True, - help_text=_("Custom object fields within the same group will be displayed together"), + help_text=_( + "Custom object fields within the same group will be displayed together" + ), ) description = models.CharField( verbose_name=_("description"), max_length=200, blank=True @@ -862,6 +876,40 @@ def clean(self): {"unique": _("Uniqueness cannot be enforced for boolean fields")} ) + # Check if uniqueness constraint can be applied when changing from non-unique to unique + if ( + self.pk + and self.unique + and not self.original.unique + and not self._state.adding + ): + field_type = FIELD_TYPE_CLASS[self.type]() + model_field = field_type.get_model_field(self) + model = self.custom_object_type.get_model() + model_field.contribute_to_class(model, self.name) + + old_field = field_type.get_model_field(self.original) + old_field.contribute_to_class(model, self._original_name) + + try: + with connection.schema_editor() as test_schema_editor: + test_schema_editor.alter_field(model, old_field, model_field) + # If we get here, the constraint was applied successfully + # Now raise a custom exception to rollback the test transaction + raise UniquenessConstraintTestError() + except UniquenessConstraintTestError: + # The constraint can be applied, validation passes + pass + except IntegrityError: + # The constraint cannot be applied due to existing non-unique values + raise ValidationError( + { + "unique": _( + "Custom objects with non-unique values already exist so this action isn't permitted" + ) + } + ) + # Choice set must be set on selection fields, and *only* on selection fields if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, From c111f867c3aceadbd3445a3881bbe211ac901389 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 19 Aug 2025 15:09:11 -0700 Subject: [PATCH 04/10] NPL-411 raise validation error if using reserved names for fields --- netbox_custom_objects/constants.py | 16 ++++++++++++++++ netbox_custom_objects/models.py | 12 +++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/constants.py b/netbox_custom_objects/constants.py index ef5bc60..5ae577e 100644 --- a/netbox_custom_objects/constants.py +++ b/netbox_custom_objects/constants.py @@ -6,3 +6,19 @@ ) APP_LABEL = "netbox_custom_objects" + +# Field names that are reserved and cannot be used for custom object fields +RESERVED_FIELD_NAMES = [ + "bookmarks", + "contacts", + "created", + "custom_field_data", + "id", + "images", + "jobs", + "journal_entries", + "last_updated", + "pk", + "subscriptions", + "tags", +] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 9ec2d4c..fa2f160 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -49,7 +49,7 @@ from utilities.string import title from utilities.validators import validate_regex -from netbox_custom_objects.constants import APP_LABEL +from netbox_custom_objects.constants import APP_LABEL, RESERVED_FIELD_NAMES from netbox_custom_objects.field_types import FIELD_TYPE_CLASS USER_TABLE_DATABASE_NAME_PREFIX = "custom_objects_" @@ -799,6 +799,16 @@ def related_object_type_label(self): def clean(self): super().clean() + # Check if the field name is reserved + if self.name in RESERVED_FIELD_NAMES: + raise ValidationError( + { + "name": _( + 'Field name "{name}" is reserved and cannot be used. Reserved names are: {reserved_names}' + ).format(name=self.name, reserved_names=", ".join(RESERVED_FIELD_NAMES)) + } + ) + # Validate the field's default value (if any) if self.default is not None: try: From 1f0197bbce7855e32c44a0bf25baecfb21a57712 Mon Sep 17 00:00:00 2001 From: bctiemann Date: Wed, 20 Aug 2025 09:54:54 -0400 Subject: [PATCH 05/10] Revert "Check if unique flag raise validation error if non unique data" --- netbox_custom_objects/models.py | 58 +++------------------------------ 1 file changed, 5 insertions(+), 53 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index cd089c4..fa2f160 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -12,7 +12,7 @@ # from django.contrib.contenttypes.management import create_contenttypes from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator, ValidationError -from django.db import connection, models, IntegrityError +from django.db import connection, models from django.db.models import Q from django.db.models.functions import Lower from django.db.models.signals import pre_delete @@ -52,13 +52,6 @@ from netbox_custom_objects.constants import APP_LABEL, RESERVED_FIELD_NAMES from netbox_custom_objects.field_types import FIELD_TYPE_CLASS - -class UniquenessConstraintTestError(Exception): - """Custom exception used to signal successful uniqueness constraint test.""" - - pass - - USER_TABLE_DATABASE_NAME_PREFIX = "custom_objects_" @@ -317,10 +310,7 @@ def _fetch_and_generate_field_attrs( for field in fields: field_type = FIELD_TYPE_CLASS[field.type]() if skip_object_fields: - if field.type in [ - CustomFieldTypeChoices.TYPE_OBJECT, - CustomFieldTypeChoices.TYPE_MULTIOBJECT, - ]: + if field.type in [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]: continue field_name = field.name @@ -463,9 +453,7 @@ def get_model( "custom_object_type_id": self.id, } - field_attrs = self._fetch_and_generate_field_attrs( - fields, skip_object_fields=skip_object_fields - ) + field_attrs = self._fetch_and_generate_field_attrs(fields, skip_object_fields=skip_object_fields) attrs.update(**field_attrs) @@ -599,7 +587,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode name = models.CharField( verbose_name=_("name"), max_length=50, - help_text=_('Internal field name, e.g. "vendor_label"'), + help_text=_("Internal field name, e.g. \"vendor_label\""), validators=( RegexValidator( regex=r"^[a-z0-9_]+$", @@ -628,9 +616,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode verbose_name=_("group name"), max_length=50, blank=True, - help_text=_( - "Custom object fields within the same group will be displayed together" - ), + help_text=_("Custom object fields within the same group will be displayed together"), ) description = models.CharField( verbose_name=_("description"), max_length=200, blank=True @@ -886,40 +872,6 @@ def clean(self): {"unique": _("Uniqueness cannot be enforced for boolean fields")} ) - # Check if uniqueness constraint can be applied when changing from non-unique to unique - if ( - self.pk - and self.unique - and not self.original.unique - and not self._state.adding - ): - field_type = FIELD_TYPE_CLASS[self.type]() - model_field = field_type.get_model_field(self) - model = self.custom_object_type.get_model() - model_field.contribute_to_class(model, self.name) - - old_field = field_type.get_model_field(self.original) - old_field.contribute_to_class(model, self._original_name) - - try: - with connection.schema_editor() as test_schema_editor: - test_schema_editor.alter_field(model, old_field, model_field) - # If we get here, the constraint was applied successfully - # Now raise a custom exception to rollback the test transaction - raise UniquenessConstraintTestError() - except UniquenessConstraintTestError: - # The constraint can be applied, validation passes - pass - except IntegrityError: - # The constraint cannot be applied due to existing non-unique values - raise ValidationError( - { - "unique": _( - "Custom objects with non-unique values already exist so this action isn't permitted" - ) - } - ) - # Choice set must be set on selection fields, and *only* on selection fields if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, From 1bc72cd20c37f90002319c6bc67aa1fdb1ae9e60 Mon Sep 17 00:00:00 2001 From: Mark Coleman Date: Wed, 20 Aug 2025 16:53:28 +0200 Subject: [PATCH 06/10] Update docs for 0.2.0 --- README.md | 5 +-- SECURITY.md | 2 +- docs/api.md | 42 ++++++++++++++++++++-- docs/index.md | 99 +++++++++++++++++++++------------------------------ 4 files changed, 83 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index aa52970..5aa6d7d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ You can find further documentation [here](https://github.com/netboxlabs/netbox-c ## Requirements -* NetBox v4.2 or later +* NetBox v4.4-beta or later ## Installation @@ -30,6 +30,7 @@ PLUGINS = [ ``` $ ./manage.py migrate ``` + 4. Restart NetBox ``` sudo systemctl restart netbox netbox-rq @@ -37,4 +38,4 @@ sudo systemctl restart netbox netbox-rq ## Known Limitations -The Public Preview of NetBox Custom Objects is under active development as we proceed towards the General Availability release around NetBox 4.4. The best place to look for the latest list of known limitations is the [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) list on the GitHub repository. These include features like Tags, Import/Export, Bulk Edit, Text Search and Branching. +The Public Preview of NetBox Custom Objects is under active development as we proceed towards the General Availability release around NetBox 4.4. The best place to look for the latest list of known limitations is the [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) list on the GitHub repository. diff --git a/SECURITY.md b/SECURITY.md index 3dd2d6f..6da9cfb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,4 +2,4 @@ ## Reporting a Vulnerability -Please send any suspected vulnerability report to security@netboxlabs.com \ No newline at end of file +Please send any suspected vulnerability report to [security@netboxlabs.com](mailto:security@netboxlabs.com) \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 572f8d3..1b92fc4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,7 +28,7 @@ similar to the following: ### Custom Object Type Fields Define the schema of the Custom Object Type by creating fields of various types, with POST requests to -`/api/plugins/custom-objects/custom-object-type-fields/`: +`/api/plugins/custom-objects/custom-object-type-fields/`, referencing the ID of the Custom Object Type you just created: ```json { @@ -166,8 +166,44 @@ The response will include the created object with its assigned ID and additional } ``` -PATCH requests can be used to update all the above objects, as well as DELETE and GET operations, using the detail -URL for each model: +### API output in the browser + +As with other NetBox objects, you can also view the API output for given Custom Objects by prepending `api/` to the URL, e.g. `api/plugins/custom-objects/dhcp_scope/` + +``` +HTTP 200 OK +Allow: GET, POST, HEAD, OPTIONS +Content-Type: application/json +Vary: Accept + +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "url": "http://localhost:8001/api/plugins/custom-objects/dhcp_scope/1/", + "range": { + "id": 1, + "url": "http://localhost:8001/api/ipam/ip-ranges/1/", + "display": "192.168.0.1-100/24", + "family": { + "value": 4, + "label": "IPv4" + }, + "start_address": "192.168.0.1/24", + "end_address": "192.168.0.100/24", + "description": "" + } + } + ] +} +``` + +### Other operations + +`PATCH`, `DELETE` and `GET` requests can be used on all of the above, using the detail URL for each model: - Custom Object Types: `/api/plugins/custom-objects/custom-object-types/15/` - Custom Object Type Fields: `/api/plugins/custom-objects/custom-object-type-fields/23/` - Custom Objects: `/api/plugins/custom-objects//15/` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 3796df4..273e9a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,36 @@ # NetBox Custom Objects -[NetBox](https://github.com/netbox-community/netbox) is the world's leading source of truth for infrastructure, featuring an extensive and complex data model. But sometimes it can be useful to extend the NetBox data model to fit specific organizational needs. This plugin introduces a new paradigm for NetBox to help overcome these challenges: custom objects. +[NetBox](https://github.com/netbox-community/netbox) is the world's leading source of truth for infrastructure, featuring an extensive data model. Sometimes it can be useful to extend the NetBox data model to fit specific organizational needs. The Custom Objects plugin introduces a new paradigm for NetBox to help overcome these challenges, allowing NetBox adminstrators to extend the NetBox data model without writing a line of code. For additional documentation on the REST API, go [here](api.md). -## Features - -* Easily create new objects in NetBox - via the GUI, the REST API or `pynetbox` - -* Each custom object inherits standard NetBox model functionality like REST APIs, list views, detail views and more +> [!TIP] +> NetBox Custom Objects is still in Public Preview. If you hit any problems please check the [exiting issues](https://github.com/netboxlabs/netbox-custom-objects/issues) before creating a new one. If you're unsure, start a [discussion](https://github.com/netboxlabs/netbox-custom-objects/discussions). -* Custom Objects can include fields of all standard types, like text, decimal, integer, boolean, choicesets, and more, but can also have 1-1 and 1-many fields to core NetBox models, plugin models and other Custom Object Types you have created. +## Features -* Fields on Custom Objects can model additional behaviour like uniqueness, default values, layout hints, required fields, and more. +* Easily create new object types in NetBox - via the GUI, the REST API or `pynetbox` + +* Each Custom Object Type inherits standard NetBox model functionality including: + * List views, details views, etc + * Group-able fields + * An entry in the left pane for intuitive navigation + * Create Custom Fields that point to Custom Object Types + * REST APIs + * Search + * Changelogging + * Bookmarks + * Custom Links + * Cloning + * Import/Export + * EventRules + * Notifications + * Journaling + * Tags + +* Custom Object Types can include 1-1 and 1-many Custom Object Type Fields of all standard types, like text, decimal, integer, boolean, etc, and can also include fields of choiceset, core NetBox models, plugin models and other Custom Object Types you have created. + +* Custom Object Type Fields can model additional behaviour like uniqueness, default values, layout hints, required fields, and more. ## Terminology @@ -33,7 +51,7 @@ Let's walk through the above DHCP Scope example, to highlight the steps involved 2. Choose a name for your Custom Object Type. In this case we will choose `dhcp_scope` > [!TIP] -> Give your Custom Object Types URL friendly names +> Give your Custom Object Types URL friendly names > [!TIP] > By default the plural name for your Custom Object Type will be its name with `s` appended. So for example, multiple `dhcp_scope` Custom Objects will be referred to as `dhcp_scopes`. @@ -49,83 +67,46 @@ Let's walk through the above DHCP Scope example, to highlight the steps involved > The `Primary` flag on Custom Object Type Fields is used for Custom Object naming. By default when you create a Custom Object it will be called ` `. So in this example the first `dhcp_scope` we create would be called `dhcp_scope 1` and so on. > Setting `Primary` to `true` on a Custom Object Type Field causes the value of that field to be used as the name for the Custom Object. +> [!TIP] +> Uniqueness cannot be enforced for Custom Object Type Fields of type `MultiObject` or `boolean` + + 2. Specify a `Name` for your field, in this case we'll choose a URL friendly value: `range`. 3. Specify the `Label` for your field. This is a human readable name that will be used in the GUI. In this case we'll choose `DHCP Range`. 4. Choose a `Type` for your field. In this case we want our `range` field to be a 1-1 reference to a built-in NetBox object type, so we choose `Object`. 5. Then we need to specify which type of built-in object our `range` field will reference. Scroll down to `Related object type` and choose `IPAM > IP Range`. 6. Then click `Create`. -> [!NOTE] -> Some behaviour on Custom Object Type Fields is still under active development during the Public Preview. Please check the outstanding [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) and [discussions](https://github.com/netboxlabs/netbox-custom-objects/discussions) before creating a new one. + ### Interacting with Custom Objects Typically, NetBox admins would be responsible for thinking through modelling requirements, and creating new Custom Object Types for other users to use in their day to day work. You have now created a new `DHCP Scope` Custom Object Type, so let's look at how others would interact with them. -> [!NOTE] -> When NetBox Custom Objects reaches General Availability, it will be possible to add new Custom Object Types in the left navigation pane, like other core NetBox or Plugin objects. Until then the instructions below outline the correct approach. - #### Creating a new Custom Object Now that you've created your `DHCP Scope` Custom Object Type, let's go ahead and create a new `DHCP Scope`. -1. On the DHCP Scope detail view, click `+ Add` in the bottom right +1. Under the NetBox Custom Objects plugin in the left side navigation you will now see `Dhcp_scopes`. Click on `+` next to your new Custom Object Type. 2. As you added a single field, called `Range` of type `IPAM > IP Range` you are prompted to specify a range. Go and ahead and select one, then click `Create`. -3. You'll now see that your new `dhcp_scope` has been added into the list view at the bottom of the `DHCP Scope` Custom Object Type page. +3. You're now taken to the detail view for your new `DHCP Scope` object. #### Standard list views for Custom Objects -As you saw in the previous step, all Custom Objects of a given Custom Object Type are viewable at the bottom of the Custom Object Type's detail page, but you can also view standard list views: - -1. On the `DHCP Scope` detail view page, right click on `Dhcp_scopes` (you can also navigate to `plugins/custom-objects/dhcp_scope/`) -2. Now you will see a standard NetBox list view of your `dhcp_scopes` with the standard options including `Configure Table`, `+ Add`, etc - -> [!NOTE] -> When NetBox Custom Objects reaches General Availability, it will be possible to navigate to Custom Object list views in the left navigation pane, as with core NetBox or Plugin objects. Until then the instructions above outline the correct approach. - -3. As with other NetBox objects, you can also view the API output for given Custom Objects by prepending `api/` to the URL, e.g. `api/plugins/custom-objects/dhcp_scope/` - -``` -HTTP 200 OK -Allow: GET, POST, HEAD, OPTIONS -Content-Type: application/json -Vary: Accept - -{ - "count": 1, - "next": null, - "previous": null, - "results": [ - { - "id": 1, - "url": "http://localhost:8001/api/plugins/custom-objects/dhcp_scope/1/", - "range": { - "id": 1, - "url": "http://localhost:8001/api/ipam/ip-ranges/1/", - "display": "192.168.0.1-100/24", - "family": { - "value": 4, - "label": "IPv4" - }, - "start_address": "192.168.0.1/24", - "end_address": "192.168.0.100/24", - "description": "" - } - } - ] -} -``` +As with core NetBox objects, Custom Objects have their own list views. To see all your `DHCP Scopes` you can just click on your Custom Object Type in the Custom Object plugin section in the left side navigation. In the example above, click on `Custom Objects` -> `OBJECTS` -> `Dhcp_scopes` + +You will now see a standard NetBox list view for your new Custom Objects with the standard options including `Configure Table`, `+ Add`, Import, Export, etc ### Deletions #### Deleting Custom Object Types -When deleting a Custom Object Type, you are in effect, deleting an entire table in the database and should be done with caution. You will be warned about the impact of before you proceed, and this is why we suggest that only admins should be allowed to interact with Custom Objects Types. +When deleting a Custom Object Type, you are in effect, deleting an entire table in the database and this should be done with caution. You will be warned about the impact of before you proceed, and this is why we suggest that only admins should be allowed to interact with Custom Objects Types. #### Deleting Custom Object Type Fields -When deleting a Custom Object Type Field, you are in effect, deleting an entire column in the database and should be done with caution. You will be warned about the impact of before you proceed, and this is why we suggest that only admins should be allowed to interact with Custom Objects Type Fields. +When deleting a Custom Object Type Field, you are in effect, deleting an entire column in the database and this should be done with caution. You will be warned about the impact of before you proceed, and this is why we suggest that only admins should be allowed to interact with Custom Objects Type Fields. #### Deleting Custom Objects -Deleting Custom Objects, like a specific DHCP Scope object you have created, works just like it does for normal NetBox objects. You can delete a Custom Object on the Custom Object's detail view, or via one of the two list views. We recommend that you follow your usual permissions practices here. \ No newline at end of file +Deleting Custom Objects, like a specific DHCP Scope object you have created, works just like it does for normal NetBox objects. You can delete a Custom Object on the Custom Object's detail view, or via the list view. We recommend that you follow your usual permissions practices here. \ No newline at end of file From 91335ba72a248e0adb45e149bc395edee00eee14 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 20 Aug 2025 09:24:38 -0700 Subject: [PATCH 07/10] NPL-389 fix unique check --- netbox_custom_objects/models.py | 46 ++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index fa2f160..3cc0caf 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -12,7 +12,7 @@ # from django.contrib.contenttypes.management import create_contenttypes from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator, ValidationError -from django.db import connection, models +from django.db import connection, IntegrityError, models, transaction from django.db.models import Q from django.db.models.functions import Lower from django.db.models.signals import pre_delete @@ -52,6 +52,13 @@ from netbox_custom_objects.constants import APP_LABEL, RESERVED_FIELD_NAMES from netbox_custom_objects.field_types import FIELD_TYPE_CLASS + +class UniquenessConstraintTestError(Exception): + """Custom exception used to signal successful uniqueness constraint test.""" + + pass + + USER_TABLE_DATABASE_NAME_PREFIX = "custom_objects_" @@ -872,6 +879,43 @@ def clean(self): {"unique": _("Uniqueness cannot be enforced for boolean fields")} ) + # Check if uniqueness constraint can be applied when changing from non-unique to unique + if ( + self.pk + and self.unique + and not self.original.unique + and not self._state.adding + ): + field_type = FIELD_TYPE_CLASS[self.type]() + model_field = field_type.get_model_field(self) + model = self.custom_object_type.get_model() + model_field.contribute_to_class(model, self.name) + + old_field = field_type.get_model_field(self.original) + old_field.contribute_to_class(model, self._original_name) + + try: + with transaction.atomic(): + with connection.schema_editor() as test_schema_editor: + test_schema_editor.alter_field(model, old_field, model_field) + # If we get here, the constraint was applied successfully + # Now raise a custom exception to rollback the test transaction + raise UniquenessConstraintTestError() + except UniquenessConstraintTestError: + # The constraint can be applied, validation passes + pass + except IntegrityError: + # The constraint cannot be applied due to existing non-unique values + raise ValidationError( + { + "unique": _( + "Custom objects with non-unique values already exist so this action isn't permitted" + ) + } + ) + finally: + self.custom_object_type.clear_model_cache(self.custom_object_type.id) + # Choice set must be set on selection fields, and *only* on selection fields if self.type in ( CustomFieldTypeChoices.TYPE_SELECT, From b311f7b486ce567d24e668fa8bda34ff7251c6cb Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 20 Aug 2025 14:12:08 -0400 Subject: [PATCH 08/10] Bump version to 0.2.0 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c2b916..67f549f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netboxlabs-netbox-custom-objects" -version = "0.1.0" +version = "0.2.0" description = "A plugin to manage custom objects in NetBox" readme = "README.md" requires-python = ">=3.10" From 34fb65344cc90ed723e75f7df0750c7d6b058294 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 20 Aug 2025 11:57:53 -0700 Subject: [PATCH 09/10] 174 fix delete of CO on detail page --- netbox_custom_objects/views.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index bd10e9f..e978372 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -543,6 +543,19 @@ def get_object(self, **kwargs): model = object_type.get_model() return get_object_or_404(model.objects.all(), **self.kwargs) + def get_return_url(self, request, obj=None): + """ + Return the URL to redirect to after deleting a custom object. + """ + if obj: + # Get the custom object type from the object directly + custom_object_type = obj.custom_object_type.name + else: + # Fallback to getting it from kwargs if object is not available + custom_object_type = self.kwargs.get("custom_object_type") + + return reverse("plugins:netbox_custom_objects:customobject_list", kwargs={"custom_object_type": custom_object_type}) + @register_model_view(CustomObject, "bulk_edit", path="edit", detail=False) class CustomObjectBulkEditView(CustomObjectTableMixin, generic.BulkEditView): From 3216a936d55133be3cfa1a43ab0fdf24a6d724c1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 20 Aug 2025 11:59:52 -0700 Subject: [PATCH 10/10] 174 fix delete of CO on detail page --- netbox_custom_objects/views.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index e978372..44fa7b9 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -31,7 +31,7 @@ from . import field_types, filtersets, forms, tables from .models import CustomObject, CustomObjectType, CustomObjectTypeField -logger = logging.getLogger('netbox_custom_objects.views') +logger = logging.getLogger("netbox_custom_objects.views") class CustomJournalEntryForm(JournalEntryForm): @@ -123,7 +123,9 @@ def get_table(self, data, request, bulk_actions=True): attrs[field.name] = field_type.get_table_column_field(field) except NotImplementedError: logger.debug( - "table mixin: {} field is not implemented; using a default column".format(field.name) + "table mixin: {} field is not implemented; using a default column".format( + field.name + ) ) # Define a method "render_table_column" method on any FieldType to customize output # See https://django-tables2.readthedocs.io/en/latest/pages/custom-data.html#table-render-foo-methods @@ -553,8 +555,11 @@ def get_return_url(self, request, obj=None): else: # Fallback to getting it from kwargs if object is not available custom_object_type = self.kwargs.get("custom_object_type") - - return reverse("plugins:netbox_custom_objects:customobject_list", kwargs={"custom_object_type": custom_object_type}) + + return reverse( + "plugins:netbox_custom_objects:customobject_list", + kwargs={"custom_object_type": custom_object_type}, + ) @register_model_view(CustomObject, "bulk_edit", path="edit", detail=False) @@ -591,7 +596,9 @@ def get_form(self, queryset): try: attrs[field.name] = field_type.get_annotated_form_field(field) except NotImplementedError: - logger.debug("bulk edit form: {} field is not supported".format(field.name)) + logger.debug( + "bulk edit form: {} field is not supported".format(field.name) + ) form = type( f"{queryset.model._meta.object_name}BulkEditForm",