From 3f21c6e48422dc738321498ff2badb65f2cef71b Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 7 Apr 2026 21:05:14 -0400 Subject: [PATCH 1/5] Validate semver fields --- ...alter_customobjecttype_version_and_more.py | 34 +++++++++ netbox_custom_objects/models.py | 19 ++++- netbox_custom_objects/tests/test_models.py | 76 +++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py diff --git a/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py b/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py new file mode 100644 index 0000000..3d0e547 --- /dev/null +++ b/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py @@ -0,0 +1,34 @@ +# 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', '0006_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_semver]), + ), + migrations.AlterField( + model_name='customobjecttypefield', + name='deprecated_since', + field=models.CharField(blank=True, max_length=50, validators=[netbox_custom_objects.models._validate_semver]), + ), + migrations.AlterField( + model_name='customobjecttypefield', + name='scheduled_removal', + field=models.CharField(blank=True, max_length=50, validators=[netbox_custom_objects.models._validate_semver]), + ), + migrations.AlterField( + model_name='customobjecttypefield', + name='schema_id', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index ba395d0..f61bd70 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -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 @@ -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_semver(value): + """Validate that *value* is a valid PEP 440 / semver-style 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 = {} @@ -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_semver]) 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) @@ -962,12 +977,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_semver], ) 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_semver], ) clone_fields = ("custom_object_type",) diff --git a/netbox_custom_objects/tests/test_models.py b/netbox_custom_objects/tests/test_models.py index d2602b2..e13ace9 100644 --- a/netbox_custom_objects/tests/test_models.py +++ b/netbox_custom_objects/tests/test_models.py @@ -900,3 +900,79 @@ def test_duplicate_job_not_enqueued(self): self.assertEqual(first_job.pk, second_job.pk) self.assertEqual(Job.objects.filter(data__cot_id=self.cot.pk).count(), 1) + + +# --------------------------------------------------------------------------- +# 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') + field.deprecated_since = 'not-a-version' + with self.assertRaises(ValidationError): + 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') + field.scheduled_removal = 'v-bad' + with self.assertRaises(ValidationError): + field.full_clean() From 6e941e1e69b18eab3344fafc878e4b83455404ad Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 7 Apr 2026 21:07:20 -0400 Subject: [PATCH 2/5] Ruff fixes --- ..._alter_customobjecttype_version_and_more.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py b/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py index 3d0e547..0136b51 100644 --- a/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py +++ b/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py @@ -14,17 +14,29 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='customobjecttype', name='version', - field=models.CharField(blank=True, max_length=50, validators=[netbox_custom_objects.models._validate_semver]), + field=models.CharField( + blank=True, + max_length=50, + validators=[netbox_custom_objects.models._validate_semver], + ), ), migrations.AlterField( model_name='customobjecttypefield', name='deprecated_since', - field=models.CharField(blank=True, max_length=50, validators=[netbox_custom_objects.models._validate_semver]), + field=models.CharField( + blank=True, + max_length=50, + validators=[netbox_custom_objects.models._validate_semver], + ), ), migrations.AlterField( model_name='customobjecttypefield', name='scheduled_removal', - field=models.CharField(blank=True, max_length=50, validators=[netbox_custom_objects.models._validate_semver]), + field=models.CharField( + blank=True, + max_length=50, + validators=[netbox_custom_objects.models._validate_semver], + ), ), migrations.AlterField( model_name='customobjecttypefield', From 3a968bc808289a1af1b27f344eff7a63a5ab64b6 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 9 Apr 2026 05:55:12 -0400 Subject: [PATCH 3/5] Fix migrations --- ..._more.py => 0009_alter_customobjecttype_version_and_more.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox_custom_objects/migrations/{0007_alter_customobjecttype_version_and_more.py => 0009_alter_customobjecttype_version_and_more.py} (95%) diff --git a/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py b/netbox_custom_objects/migrations/0009_alter_customobjecttype_version_and_more.py similarity index 95% rename from netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py rename to netbox_custom_objects/migrations/0009_alter_customobjecttype_version_and_more.py index 0136b51..7d92786 100644 --- a/netbox_custom_objects/migrations/0007_alter_customobjecttype_version_and_more.py +++ b/netbox_custom_objects/migrations/0009_alter_customobjecttype_version_and_more.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('netbox_custom_objects', '0006_backfill_schema_ids'), + ('netbox_custom_objects', '0008_backfill_schema_ids'), ] operations = [ From 5c3906aa7b8fa4dc5843b738a250cc337124ed96 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 9 Apr 2026 06:23:39 -0400 Subject: [PATCH 4/5] Cleanup and test coverage --- ...=> 0009_alter_customobjecttype_version.py} | 11 +-- netbox_custom_objects/models.py | 10 +-- netbox_custom_objects/tests/test_api.py | 90 +++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 99 insertions(+), 13 deletions(-) rename netbox_custom_objects/migrations/{0009_alter_customobjecttype_version_and_more.py => 0009_alter_customobjecttype_version.py} (69%) diff --git a/netbox_custom_objects/migrations/0009_alter_customobjecttype_version_and_more.py b/netbox_custom_objects/migrations/0009_alter_customobjecttype_version.py similarity index 69% rename from netbox_custom_objects/migrations/0009_alter_customobjecttype_version_and_more.py rename to netbox_custom_objects/migrations/0009_alter_customobjecttype_version.py index 7d92786..5eff753 100644 --- a/netbox_custom_objects/migrations/0009_alter_customobjecttype_version_and_more.py +++ b/netbox_custom_objects/migrations/0009_alter_customobjecttype_version.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): field=models.CharField( blank=True, max_length=50, - validators=[netbox_custom_objects.models._validate_semver], + validators=[netbox_custom_objects.models.validate_pep440], ), ), migrations.AlterField( @@ -26,7 +26,7 @@ class Migration(migrations.Migration): field=models.CharField( blank=True, max_length=50, - validators=[netbox_custom_objects.models._validate_semver], + validators=[netbox_custom_objects.models.validate_pep440], ), ), migrations.AlterField( @@ -35,12 +35,7 @@ class Migration(migrations.Migration): field=models.CharField( blank=True, max_length=50, - validators=[netbox_custom_objects.models._validate_semver], + validators=[netbox_custom_objects.models.validate_pep440], ), ), - migrations.AlterField( - model_name='customobjecttypefield', - name='schema_id', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), ] diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index e3893f6..01cf3dd 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -164,8 +164,8 @@ def _get_action_url(cls, action=None, rest_api=False, kwargs=None): return reverse(cls._get_viewname(action, rest_api), kwargs=kwargs) -def _validate_semver(value): - """Validate that *value* is a valid PEP 440 / semver-style version string.""" +def validate_pep440(value): + """Validate that *value* is a valid PEP 440 version string.""" if not value: return try: @@ -212,7 +212,7 @@ class CustomObjectType(NetBoxModel): verbose_name=_('comments'), blank=True ) - version = models.CharField(max_length=50, blank=True, validators=[_validate_semver]) + 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) @@ -1012,14 +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_semver], + 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_semver], + validators=[validate_pep440], ) clone_fields = ("custom_object_type",) diff --git a/netbox_custom_objects/tests/test_api.py b/netbox_custom_objects/tests/test_api.py index 32ec716..d68c0ca 100644 --- a/netbox_custom_objects/tests/test_api.py +++ b/netbox_custom_objects/tests/test_api.py @@ -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 @@ -959,3 +960,92 @@ def test_multiple_context_fields_omits_empty_values(self): 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) diff --git a/pyproject.toml b/pyproject.toml index 7b967ef..fb393cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ dependencies = [ "Django", + "packaging", ] [project.optional-dependencies] From 5b2a668c3b976e333ad872a60710f2c9eec69ce8 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 9 Apr 2026 07:37:22 -0400 Subject: [PATCH 5/5] Test improvements/cleanup --- netbox_custom_objects/tests/test_api.py | 17 +++++++++-------- netbox_custom_objects/tests/test_models.py | 14 ++++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/netbox_custom_objects/tests/test_api.py b/netbox_custom_objects/tests/test_api.py index d68c0ca..8c43d42 100644 --- a/netbox_custom_objects/tests/test_api.py +++ b/netbox_custom_objects/tests/test_api.py @@ -792,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( @@ -890,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') @@ -899,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']) @@ -909,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']) @@ -921,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) @@ -930,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']) @@ -944,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']) @@ -955,7 +956,7 @@ 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']) diff --git a/netbox_custom_objects/tests/test_models.py b/netbox_custom_objects/tests/test_models.py index b7fe5f4..b7d816e 100644 --- a/netbox_custom_objects/tests/test_models.py +++ b/netbox_custom_objects/tests/test_models.py @@ -1240,9 +1240,10 @@ def test_field_deprecated_since_valid_semver(self): 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') - field.deprecated_since = 'not-a-version' - with self.assertRaises(ValidationError): - field.full_clean() + 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 @@ -1263,6 +1264,7 @@ def test_field_scheduled_removal_valid_semver(self): 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') - field.scheduled_removal = 'v-bad' - with self.assertRaises(ValidationError): - field.full_clean() + 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()