Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 5.2.12 on 2026-04-08 01:04

import netbox_custom_objects.models
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('netbox_custom_objects', '0008_backfill_schema_ids'),
]

operations = [
migrations.AlterField(
model_name='customobjecttype',
name='version',
field=models.CharField(
blank=True,
max_length=50,
validators=[netbox_custom_objects.models.validate_pep440],
),
),
migrations.AlterField(
model_name='customobjecttypefield',
name='deprecated_since',
field=models.CharField(
blank=True,
max_length=50,
validators=[netbox_custom_objects.models.validate_pep440],
),
),
migrations.AlterField(
model_name='customobjecttypefield',
name='scheduled_removal',
field=models.CharField(
blank=True,
max_length=50,
validators=[netbox_custom_objects.models.validate_pep440],
),
),
]
19 changes: 18 additions & 1 deletion netbox_custom_objects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import threading
from datetime import date, datetime

from packaging.version import Version, InvalidVersion

import django_filters
from core.models import ObjectType, ObjectChange
from core.models.object_types import ObjectTypeManager
Expand Down Expand Up @@ -162,6 +164,19 @@ def _get_action_url(cls, action=None, rest_api=False, kwargs=None):
return reverse(cls._get_viewname(action, rest_api), kwargs=kwargs)


def validate_pep440(value):
"""Validate that *value* is a valid PEP 440 version string."""
if not value:
return
try:
Version(value)
except InvalidVersion:
raise ValidationError(
_("'%(value)s' is not a valid version string (expected e.g. '1.0.0')."),
params={"value": value},
)


class CustomObjectType(NetBoxModel):
# Class-level cache for generated models
_model_cache = {}
Expand Down Expand Up @@ -197,7 +212,7 @@ class CustomObjectType(NetBoxModel):
verbose_name=_('comments'),
blank=True
)
version = models.CharField(max_length=50, blank=True)
version = models.CharField(max_length=50, blank=True, validators=[validate_pep440])
verbose_name = models.CharField(max_length=100, blank=True)
verbose_name_plural = models.CharField(max_length=100, blank=True)
slug = models.SlugField(max_length=100, unique=True, db_index=True, blank=False)
Expand Down Expand Up @@ -997,12 +1012,14 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode
blank=True,
verbose_name=_("deprecated since"),
help_text=_("Schema version in which this field was marked deprecated (e.g. '2.0.0')."),
validators=[validate_pep440],
)
scheduled_removal = models.CharField(
max_length=50,
blank=True,
verbose_name=_("scheduled removal"),
help_text=_("Schema version in which this field is planned to be removed (e.g. '3.0.0')."),
validators=[validate_pep440],
)

clone_fields = ("custom_object_type",)
Expand Down
107 changes: 99 additions & 8 deletions netbox_custom_objects/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from utilities.testing import APIViewTestCases, create_test_user
from rest_framework import status
from rest_framework.test import APIClient

from netbox_custom_objects.models import CustomObjectType
from .base import CustomObjectsTestCase
Expand Down Expand Up @@ -791,7 +792,8 @@ class ContextFieldApiTestCase(CustomObjectsTestCase, TestCase):
def setUp(self):
self.user = create_test_user('ctxapiuser')
token_key = create_token(self.user)
self.header = {'HTTP_AUTHORIZATION': f'Token {token_key}'}
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Token {token_key}')

# --- COT A: primary field + context field ---
self.cot_with_primary = CustomObjectsTestCase.create_custom_object_type(
Expand Down Expand Up @@ -889,7 +891,7 @@ def test_display_equals_primary_field_value(self):
"""display must be the primary field value, not the fallback."""
instance = self.model_with_primary.objects.create(name='Route-A', owner='Alice')
response = self.client.get(
self._detail_url(self.cot_with_primary, instance), **self.header
self._detail_url(self.cot_with_primary, instance)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['display'], 'Route-A')
Expand All @@ -898,7 +900,7 @@ def test_context_display_value_with_primary_field(self):
"""_context.display must equal the context field value when primary is set."""
instance = self.model_with_primary.objects.create(name='Route-A', owner='Alice')
response = self.client.get(
self._detail_url(self.cot_with_primary, instance), **self.header
self._detail_url(self.cot_with_primary, instance)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNotNone(response.data['_context'])
Expand All @@ -908,7 +910,7 @@ def test_context_null_when_context_field_has_no_value(self):
"""_context must be null when the context field carries no value."""
instance = self.model_with_primary.objects.create(name='Route-B')
response = self.client.get(
self._detail_url(self.cot_with_primary, instance), **self.header
self._detail_url(self.cot_with_primary, instance)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNone(response.data['_context'])
Expand All @@ -920,7 +922,7 @@ def test_display_uses_fallback_when_no_primary_field(self):
instance = self.model_no_primary.objects.create(owner='Bob')
expected = f"{self.cot_no_primary.display_name} {instance.id}"
response = self.client.get(
self._detail_url(self.cot_no_primary, instance), **self.header
self._detail_url(self.cot_no_primary, instance)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['display'], expected)
Expand All @@ -929,7 +931,7 @@ def test_context_display_value_with_fallback_display(self):
"""_context.display must work correctly even when display uses the fallback name."""
instance = self.model_no_primary.objects.create(owner='Bob')
response = self.client.get(
self._detail_url(self.cot_no_primary, instance), **self.header
self._detail_url(self.cot_no_primary, instance)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNotNone(response.data['_context'])
Expand All @@ -943,7 +945,7 @@ def test_multiple_context_fields_joined_in_display(self):
name='Route-C', owner='Carol', region='EU'
)
response = self.client.get(
self._detail_url(self.cot_multi_ctx, instance), **self.header
self._detail_url(self.cot_multi_ctx, instance)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNotNone(response.data['_context'])
Expand All @@ -954,8 +956,97 @@ def test_multiple_context_fields_omits_empty_values(self):
instance = self.model_multi_ctx.objects.create(name='Route-D', owner='Dave')
# region (second context field) is not set
response = self.client.get(
self._detail_url(self.cot_multi_ctx, instance), **self.header
self._detail_url(self.cot_multi_ctx, instance)
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNotNone(response.data['_context'])
self.assertEqual(response.data['_context']['display'], 'Dave')


# ---------------------------------------------------------------------------
# PEP 440 version string validation — API layer (issue #392)
# ---------------------------------------------------------------------------

class Pep440APIValidationTestCase(CustomObjectsTestCase, TestCase):
"""
Verify that ``validate_pep440`` surfaces as a 400 at the API layer for
``CustomObjectType.version`` and ``CustomObjectTypeField.deprecated_since``
/ ``scheduled_removal``.

DRF's ModelSerializer copies model-field validators into the serializer
field, so these should be enforced during deserialization without any
extra serializer code.
"""

def setUp(self):
super().setUp()
token_key = create_token(self.user)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Token {token_key}')

# Permission to create/change CustomObjectType records.
add_cot_perm = ObjectPermission(name='pep440_add_cot', actions=['add', 'change'])
add_cot_perm.save()
add_cot_perm.users.add(self.user)
add_cot_perm.object_types.add(ObjectType.objects.get_for_model(CustomObjectType))

# Permission to change CustomObjectTypeField records.
from netbox_custom_objects.models import CustomObjectTypeField # noqa: PLC0415
change_field_perm = ObjectPermission(name='pep440_change_field', actions=['add', 'change'])
change_field_perm.save()
change_field_perm.users.add(self.user)
change_field_perm.object_types.add(ObjectType.objects.get_for_model(CustomObjectTypeField))

# ------------------------------------------------------------------
# CustomObjectType.version
# ------------------------------------------------------------------

def test_create_cot_invalid_version_returns_400(self):
url = reverse('plugins-api:netbox_custom_objects-api:customobjecttype-list')
data = {'name': 'vertest', 'slug': 'ver-test', 'version': 'not-a-version'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('version', response.data)

def test_create_cot_valid_version_accepted(self):
url = reverse('plugins-api:netbox_custom_objects-api:customobjecttype-list')
data = {'name': 'vertest2', 'slug': 'ver-test-2', 'version': '1.2.3'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)

# ------------------------------------------------------------------
# CustomObjectTypeField.deprecated_since / scheduled_removal
# ------------------------------------------------------------------

def test_patch_field_invalid_deprecated_since_returns_400(self):
cot = self.create_custom_object_type(name='pep440cot', slug='pep440-cot')
field = self.create_custom_object_type_field(cot, name='alpha', type='text')
url = reverse(
'plugins-api:netbox_custom_objects-api:customobjecttypefield-detail',
kwargs={'pk': field.pk},
)
response = self.client.patch(url, {'deprecated_since': 'latest'}, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('deprecated_since', response.data)

def test_patch_field_invalid_scheduled_removal_returns_400(self):
cot = self.create_custom_object_type(name='pep440cot2', slug='pep440-cot-2')
field = self.create_custom_object_type_field(cot, name='beta', type='text')
url = reverse(
'plugins-api:netbox_custom_objects-api:customobjecttypefield-detail',
kwargs={'pk': field.pk},
)
response = self.client.patch(url, {'scheduled_removal': '1.x.0'}, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('scheduled_removal', response.data)

def test_patch_cot_valid_version_accepted(self):
# PATCH CustomObjectType.version (no DDL on COT update) verifies the
# validator doesn't reject a valid PEP 440 string.
cot = self.create_custom_object_type(name='pep440cot3', slug='pep440-cot-3')
url = reverse(
'plugins-api:netbox_custom_objects-api:customobjecttype-detail',
kwargs={'pk': cot.pk},
)
response = self.client.patch(url, {'version': '2.0.0'}, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
78 changes: 78 additions & 0 deletions netbox_custom_objects/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1190,3 +1190,81 @@ def test_get_model_skips_db_with_migrate_in_argv(self):
finally:
sys.argv = original_argv
nco._migrations_checked = None


# ---------------------------------------------------------------------------
# Semver / version string validation (issue #392)
# ---------------------------------------------------------------------------

class SemverValidationTestCase(CustomObjectsTestCase, TestCase):
"""Validate that version-string fields reject non-PEP-440 values."""

# ------------------------------------------------------------------
# CustomObjectType.version
# ------------------------------------------------------------------

def test_cot_version_blank_is_valid(self):
cot = self.create_custom_object_type(name='semver_cot', slug='semver-cot')
cot.version = ''
cot.full_clean() # must not raise

def test_cot_version_valid_semver(self):
cot = self.create_custom_object_type(name='semver_cot2', slug='semver-cot-2')
for v in ('1.0.0', '2.3.4', '0.0.1', '1.0.0.post1', '1.0.0a1'):
cot.version = v
cot.full_clean() # must not raise

def test_cot_version_invalid_raises_validation_error(self):
cot = self.create_custom_object_type(name='semver_cot3', slug='semver-cot-3')
for bad in ('not-a-version', '1.x.0', 'latest', '!!invalid!!'):
cot.version = bad
with self.assertRaises(ValidationError, msg=f"Expected ValidationError for version={bad!r}"):
cot.full_clean()

# ------------------------------------------------------------------
# CustomObjectTypeField.deprecated_since
# ------------------------------------------------------------------

def test_field_deprecated_since_blank_is_valid(self):
cot = self.create_custom_object_type(name='semver_f1', slug='semver-f1')
field = self.create_custom_object_type_field(cot, name='alpha', type='text')
field.deprecated_since = ''
field.full_clean()

def test_field_deprecated_since_valid_semver(self):
cot = self.create_custom_object_type(name='semver_f2', slug='semver-f2')
field = self.create_custom_object_type_field(cot, name='beta', type='text')
field.deprecated_since = '2.0.0'
field.full_clean()

def test_field_deprecated_since_invalid_raises(self):
cot = self.create_custom_object_type(name='semver_f3', slug='semver-f3')
field = self.create_custom_object_type_field(cot, name='gamma', type='text')
for bad in ('not-a-version', '1.x.0', 'latest', '!!invalid!!'):
field.deprecated_since = bad
with self.assertRaises(ValidationError, msg=f"Expected ValidationError for deprecated_since={bad!r}"):
field.full_clean()

# ------------------------------------------------------------------
# CustomObjectTypeField.scheduled_removal
# ------------------------------------------------------------------

def test_field_scheduled_removal_blank_is_valid(self):
cot = self.create_custom_object_type(name='semver_f4', slug='semver-f4')
field = self.create_custom_object_type_field(cot, name='delta', type='text')
field.scheduled_removal = ''
field.full_clean()

def test_field_scheduled_removal_valid_semver(self):
cot = self.create_custom_object_type(name='semver_f5', slug='semver-f5')
field = self.create_custom_object_type_field(cot, name='epsilon', type='text')
field.scheduled_removal = '3.0.0'
field.full_clean()

def test_field_scheduled_removal_invalid_raises(self):
cot = self.create_custom_object_type(name='semver_f6', slug='semver-f6')
field = self.create_custom_object_type_field(cot, name='zeta', type='text')
for bad in ('v-bad', '1.x.0', 'latest', '!!invalid!!'):
field.scheduled_removal = bad
with self.assertRaises(ValidationError, msg=f"Expected ValidationError for scheduled_removal={bad!r}"):
field.full_clean()
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ classifiers = [

dependencies = [
"Django",
"packaging",
]

[project.optional-dependencies]
Expand Down
Loading