Initial work on #10244: Protection rules (#14097)

This commit is contained in:
Jeremy Stretch 2023-10-30 14:36:56 -04:00 committed by GitHub
parent c4e765c4a8
commit edc4a35296
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 183 additions and 13 deletions

View File

@ -87,3 +87,24 @@ The following colors are supported:
* `gray` * `gray`
* `black` * `black`
* `white` * `white`
---
## PROTECTION_RULES
!!! tip "Dynamic Configuration Parameter"
This is a mapping of models to [custom validators](../customization/custom-validation.md) against which an object is evaluated immediately prior to its deletion. If validation fails, the object is not deleted. An example is provided below:
```python
PROTECTION_RULES = {
"dcim.site": [
{
"status": {
"eq": "decommissioning"
}
},
"my_plugin.validators.Validator1",
]
}
```

View File

@ -26,6 +26,8 @@ The `CustomValidator` class supports several validation types:
* `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression) * `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression)
* `required`: A value must be specified * `required`: A value must be specified
* `prohibited`: A value must _not_ be specified * `prohibited`: A value must _not_ be specified
* `eq`: A value must be equal to the specified value
* `neq`: A value must _not_ be equal to the specified value
The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). The `required` and `prohibited` validators may be used for any field, and should be passed a value of `True`. The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). The `required` and `prohibited` validators may be used for any field, and should be passed a value of `True`.

View File

@ -491,7 +491,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
(_('Security'), ('ALLOWED_URL_SCHEMES',)), (_('Security'), ('ALLOWED_URL_SCHEMES',)),
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), (_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), (_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
(_('Validation'), ('CUSTOM_VALIDATORS',)), (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)), (_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
(_('Miscellaneous'), ( (_('Miscellaneous'), (
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL', 'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
@ -508,6 +508,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}), 'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}), 'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}), 'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
'comment': forms.Textarea(), 'comment': forms.Textarea(),
} }

View File

@ -2,8 +2,10 @@ import importlib
import logging import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed, post_save, pre_delete from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates from django_prometheus.models import model_deletes, model_inserts, model_updates
from extras.validators import CustomValidator from extras.validators import CustomValidator
@ -178,11 +180,7 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
# Custom validation # Custom validation
# #
@receiver(post_clean) def run_validators(instance, validators):
def run_custom_validators(sender, instance, **kwargs):
config = get_config()
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = config.CUSTOM_VALIDATORS.get(model_name, [])
for validator in validators: for validator in validators:
@ -198,6 +196,29 @@ def run_custom_validators(sender, instance, **kwargs):
validator(instance) validator(instance)
@receiver(post_clean)
def run_save_validators(sender, instance, **kwargs):
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
run_validators(instance, validators)
@receiver(pre_delete)
def run_delete_validators(sender, instance, **kwargs):
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(
message=e
)
)
# #
# Dynamic configuration # Dynamic configuration
# #

View File

@ -1,10 +1,13 @@
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from ipam.models import ASN, RIR from ipam.models import ASN, RIR
from dcim.choices import SiteStatusChoices
from dcim.models import Site from dcim.models import Site
from extras.validators import CustomValidator from extras.validators import CustomValidator
from utilities.exceptions import AbortRequest
class MyValidator(CustomValidator): class MyValidator(CustomValidator):
@ -14,6 +17,20 @@ class MyValidator(CustomValidator):
self.fail("Name must be foo!") self.fail("Name must be foo!")
eq_validator = CustomValidator({
'asn': {
'eq': 100
}
})
neq_validator = CustomValidator({
'asn': {
'neq': 100
}
})
min_validator = CustomValidator({ min_validator = CustomValidator({
'asn': { 'asn': {
'min': 65000 'min': 65000
@ -77,6 +94,18 @@ class CustomValidatorTest(TestCase):
validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0] validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0]
self.assertIsInstance(validator, CustomValidator) self.assertIsInstance(validator, CustomValidator)
@override_settings(CUSTOM_VALIDATORS={'ipam.asn': [eq_validator]})
def test_eq(self):
ASN(asn=100, rir=RIR.objects.first()).clean()
with self.assertRaises(ValidationError):
ASN(asn=99, rir=RIR.objects.first()).clean()
@override_settings(CUSTOM_VALIDATORS={'ipam.asn': [neq_validator]})
def test_neq(self):
ASN(asn=99, rir=RIR.objects.first()).clean()
with self.assertRaises(ValidationError):
ASN(asn=100, rir=RIR.objects.first()).clean()
@override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]})
def test_min(self): def test_min(self):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@ -147,7 +176,7 @@ class CustomValidatorConfigTest(TestCase):
@override_settings( @override_settings(
CUSTOM_VALIDATORS={ CUSTOM_VALIDATORS={
'dcim.site': ( 'dcim.site': (
'extras.tests.test_customvalidator.MyValidator', 'extras.tests.test_customvalidation.MyValidator',
) )
} }
) )
@ -159,3 +188,62 @@ class CustomValidatorConfigTest(TestCase):
Site(name='foo', slug='foo').clean() Site(name='foo', slug='foo').clean()
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Site(name='bar', slug='bar').clean() Site(name='bar', slug='bar').clean()
class ProtectionRulesConfigTest(TestCase):
@override_settings(
PROTECTION_RULES={
'dcim.site': [
{'status': {'eq': SiteStatusChoices.STATUS_DECOMMISSIONING}}
]
}
)
def test_plain_data(self):
"""
Test custom validator configuration using plain data (as opposed to a CustomValidator
class)
"""
# Create a site with a protected status
site = Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
site.save()
# Try to delete it
with self.assertRaises(AbortRequest):
with transaction.atomic():
site.delete()
# Change its status to an allowed value
site.status = SiteStatusChoices.STATUS_DECOMMISSIONING
site.save()
# Deletion should now succeed
site.delete()
@override_settings(
PROTECTION_RULES={
'dcim.site': (
'extras.tests.test_customvalidation.MyValidator',
)
}
)
def test_dotted_path(self):
"""
Test custom validator configuration using a dotted path (string) reference to a
CustomValidator class.
"""
# Create a site with a protected name
site = Site(name='bar', slug='bar')
site.save()
# Try to delete it
with self.assertRaises(AbortRequest):
with transaction.atomic():
site.delete()
# Change the name to an allowed value
site.name = site.slug = 'foo'
site.save()
# Deletion should now succeed
site.delete()

View File

@ -1,15 +1,38 @@
from django.core.exceptions import ValidationError
from django.core import validators from django.core import validators
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
# NOTE: As this module may be imported by configuration.py, we cannot import # NOTE: As this module may be imported by configuration.py, we cannot import
# anything from NetBox itself. # anything from NetBox itself.
class IsEqualValidator(validators.BaseValidator):
"""
Employed by CustomValidator to require a specific value.
"""
message = _("Ensure this value is equal to %(limit_value)s.")
code = "is_equal"
def compare(self, a, b):
return a != b
class IsNotEqualValidator(validators.BaseValidator):
"""
Employed by CustomValidator to exclude a specific value.
"""
message = _("Ensure this value does not equal %(limit_value)s.")
code = "is_not_equal"
def compare(self, a, b):
return a == b
class IsEmptyValidator: class IsEmptyValidator:
""" """
Employed by CustomValidator to enforce required fields. Employed by CustomValidator to enforce required fields.
""" """
message = "This field must be empty." message = _("This field must be empty.")
code = 'is_empty' code = 'is_empty'
def __init__(self, enforce=True): def __init__(self, enforce=True):
@ -24,7 +47,7 @@ class IsNotEmptyValidator:
""" """
Employed by CustomValidator to enforce prohibited fields. Employed by CustomValidator to enforce prohibited fields.
""" """
message = "This field must not be empty." message = _("This field must not be empty.")
code = 'not_empty' code = 'not_empty'
def __init__(self, enforce=True): def __init__(self, enforce=True):
@ -50,6 +73,8 @@ class CustomValidator:
:param validation_rules: A dictionary mapping object attributes to validation rules :param validation_rules: A dictionary mapping object attributes to validation rules
""" """
VALIDATORS = { VALIDATORS = {
'eq': IsEqualValidator,
'neq': IsNotEqualValidator,
'min': validators.MinValueValidator, 'min': validators.MinValueValidator,
'max': validators.MaxValueValidator, 'max': validators.MaxValueValidator,
'min_length': validators.MinLengthValidator, 'min_length': validators.MinLengthValidator,

View File

@ -152,9 +152,17 @@ PARAMS = (
description=_("Custom validation rules (JSON)"), description=_("Custom validation rules (JSON)"),
field=forms.JSONField, field=forms.JSONField,
field_kwargs={ field_kwargs={
'widget': forms.Textarea( 'widget': forms.Textarea(),
attrs={'class': 'vLargeTextField'} },
), ),
ConfigParam(
name='PROTECTION_RULES',
label=_('Protection rules'),
default={},
description=_("Deletion protection rules (JSON)"),
field=forms.JSONField,
field_kwargs={
'widget': forms.Textarea(),
}, },
), ),

View File

@ -151,6 +151,10 @@
<th scope="row">{% trans "Custom validators" %}</th> <th scope="row">{% trans "Custom validators" %}</th>
<td>{{ object.data.CUSTOM_VALIDATORS|placeholder }}</td> <td>{{ object.data.CUSTOM_VALIDATORS|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Protection rules" %}</th>
<td>{{ object.data.PROTECTION_RULES|placeholder }}</td>
</tr>
</table> </table>
</div> </div>
</div> </div>