diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md index 9ff71758f..1b8263de3 100644 --- a/docs/configuration/data-validation.md +++ b/docs/configuration/data-validation.md @@ -87,3 +87,24 @@ The following colors are supported: * `gray` * `black` * `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", + ] +} +``` diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index 30198117f..79aa82bc9 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -26,6 +26,8 @@ The `CustomValidator` class supports several validation types: * `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression) * `required`: A value must 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`. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 42b570964..3667dabd5 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -77,6 +77,9 @@ If selected, this component will be treated as if a cable has been connected. Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. +!!! note + An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. + ### Bridged Interface Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 264fb95ba..d923bdd5d 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -16,6 +16,9 @@ The interface's name. Must be unique to the assigned VM. Identifies the parent interface of a subinterface (e.g. used to employ encapsulation). +!!! note + An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. + ### Bridged Interface An interface on the same VM with which this interface is bridged. diff --git a/docs/plugins/development/data-backends.md b/docs/plugins/development/data-backends.md new file mode 100644 index 000000000..feffa5bed --- /dev/null +++ b/docs/plugins/development/data-backends.md @@ -0,0 +1,23 @@ +# Data Backends + +[Data sources](../../models/core/datasource.md) can be defined to reference data which exists on systems of record outside NetBox, such as a git repository or Amazon S3 bucket. Plugins can register their own backend classes to introduce support for additional resource types. This is done by subclassing NetBox's `DataBackend` class. + +```python title="data_backends.py" +from netbox.data_backends import DataBackend + +class MyDataBackend(DataBackend): + name = 'mybackend' + label = 'My Backend' + ... +``` + +To register one or more data backends with NetBox, define a list named `backends` at the end of this file: + +```python title="data_backends.py" +backends = [MyDataBackend] +``` + +!!! tip + The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance. + +::: core.data_backends.DataBackend diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index dcbad9d8d..d3f50a0fb 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -109,6 +109,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | | `queues` | A list of custom background task queues to create | | `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) | +| `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) | | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | | `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | | `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) | diff --git a/mkdocs.yml b/mkdocs.yml index cc16434de..3e61f922a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -136,6 +136,7 @@ nav: - Forms: 'plugins/development/forms.md' - Filters & Filter Sets: 'plugins/development/filtersets.md' - Search: 'plugins/development/search.md' + - Data Backends: 'plugins/development/data-backends.md' - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index f4abda645..5223de339 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -85,7 +85,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer): class Meta: model = CircuitType fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index e28238fea..4dd726803 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -137,7 +137,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): class Meta: model = CircuitType - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'color', 'description'] class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -154,12 +154,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte provider_account_id = django_filters.ModelMultipleChoiceFilter( field_name='provider_account', queryset=ProviderAccount.objects.all(), - label=_('ProviderAccount (ID)'), + label=_('Provider account (ID)'), ) provider_network_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__provider_network', queryset=ProviderNetwork.objects.all(), - label=_('ProviderNetwork (ID)'), + label=_('Provider network (ID)'), ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 1a9366583..5c416bff9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -91,6 +91,10 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): + color = ColorField( + label=_('Color'), + required=False + ) description = forms.CharField( label=_('Description'), max_length=200, @@ -99,9 +103,9 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): model = CircuitType fieldsets = ( - (None, ('description',)), + (None, ('color', 'description')), ) - nullable_fields = ('description',) + nullable_fields = ('color', 'description') class CircuitBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index d2217b45b..0c30e3cda 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -3,6 +3,7 @@ from django import forms from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.models import Site +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant @@ -64,7 +65,10 @@ class CircuitTypeImportForm(NetBoxModelImportForm): class Meta: model = CircuitType - fields = ('name', 'slug', 'description', 'tags') + fields = ('name', 'slug', 'color', 'description', 'tags') + help_texts = { + 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'), + } class CircuitImportForm(NetBoxModelImportForm): diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 1fb239023..a82ec1726 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -88,7 +88,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): label=_('Provider') ) service_id = forms.CharField( - label=_('Service id'), + label=_('Service ID'), max_length=100, required=False ) @@ -97,8 +97,17 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): class CircuitTypeFilterForm(NetBoxModelFilterSetForm): model = CircuitType + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Attributes'), ('color',)), + ) tag = TagFilterField(model) + color = ColorField( + label=_('Color'), + required=False + ) + class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 8a540032e..0809cb2f4 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -76,14 +76,14 @@ class CircuitTypeForm(NetBoxModelForm): fieldsets = ( (_('Circuit Type'), ( - 'name', 'slug', 'description', 'tags', + 'name', 'slug', 'color', 'description', 'tags', )), ) class Meta: model = CircuitType fields = [ - 'name', 'slug', 'description', 'tags', + 'name', 'slug', 'color', 'description', 'tags', ] diff --git a/netbox/circuits/migrations/0043_circuittype_color.py b/netbox/circuits/migrations/0043_circuittype_color.py new file mode 100644 index 000000000..6c4dffeb6 --- /dev/null +++ b/netbox/circuits/migrations/0043_circuittype_color.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-10-20 21:25 + +from django.db import migrations +import utilities.fields + + +class Migration(migrations.Migration): + dependencies = [ + ('circuits', '0042_provideraccount'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='color', + field=utilities.fields.ColorField(blank=True, max_length=6), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 0322b67c6..4dc775364 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -7,6 +7,7 @@ from circuits.choices import * from dcim.models import CabledObjectModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin +from utilities.fields import ColorField __all__ = ( 'Circuit', @@ -20,6 +21,11 @@ class CircuitType(OrganizationalModel): Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". """ + color = ColorField( + verbose_name=_('color'), + blank=True + ) + def get_absolute_url(self): return reverse('circuits:circuittype', args=[self.pk]) diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 6a05983e6..6ae727eca 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -28,6 +28,7 @@ class CircuitTypeTable(NetBoxTable): linkify=True, verbose_name=_('Name'), ) + color = columns.ColorColumn() tags = columns.TagColumn( url_name='circuits:circuittype_list' ) @@ -40,7 +41,7 @@ class CircuitTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = CircuitType fields = ( - 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py index 4117a609c..4ae426df5 100644 --- a/netbox/core/api/serializers.py +++ b/netbox/core/api/serializers.py @@ -4,6 +4,7 @@ from core.choices import * from core.models import * from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer +from netbox.utils import get_data_backend_choices from users.api.nested_serializers import NestedUserSerializer from .nested_serializers import * @@ -19,7 +20,7 @@ class DataSourceSerializer(NetBoxModelSerializer): view_name='core-api:datasource-detail' ) type = ChoiceField( - choices=DataSourceTypeChoices + choices=get_data_backend_choices() ) status = ChoiceField( choices=DataSourceStatusChoices, @@ -68,5 +69,5 @@ class JobSerializer(BaseModelSerializer): model = Job fields = [ 'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval', - 'started', 'completed', 'user', 'data', 'job_id', + 'started', 'completed', 'user', 'data', 'error', 'job_id', ] diff --git a/netbox/core/choices.py b/netbox/core/choices.py index b5d9d0d90..8d7050414 100644 --- a/netbox/core/choices.py +++ b/netbox/core/choices.py @@ -7,18 +7,6 @@ from utilities.choices import ChoiceSet # Data sources # -class DataSourceTypeChoices(ChoiceSet): - LOCAL = 'local' - GIT = 'git' - AMAZON_S3 = 'amazon-s3' - - CHOICES = ( - (LOCAL, _('Local'), 'gray'), - (GIT, 'Git', 'blue'), - (AMAZON_S3, 'Amazon S3', 'blue'), - ) - - class DataSourceStatusChoices(ChoiceSet): NEW = 'new' QUEUED = 'queued' diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 82b3962dd..9ff0b4d63 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -10,61 +10,24 @@ from django import forms from django.conf import settings from django.utils.translation import gettext as _ -from netbox.registry import registry -from .choices import DataSourceTypeChoices +from netbox.data_backends import DataBackend +from netbox.utils import register_data_backend from .exceptions import SyncError __all__ = ( - 'LocalBackend', 'GitBackend', + 'LocalBackend', 'S3Backend', ) logger = logging.getLogger('netbox.data_backends') -def register_backend(name): - """ - Decorator for registering a DataBackend class. - """ - - def _wrapper(cls): - registry['data_backends'][name] = cls - return cls - - return _wrapper - - -class DataBackend: - parameters = {} - sensitive_parameters = [] - - # Prevent Django's template engine from calling the backend - # class when referenced via DataSource.backend_class - do_not_call_in_templates = True - - def __init__(self, url, **kwargs): - self.url = url - self.params = kwargs - self.config = self.init_config() - - def init_config(self): - """ - Hook to initialize the instance's configuration. - """ - return - - @property - def url_scheme(self): - return urlparse(self.url).scheme.lower() - - @contextmanager - def fetch(self): - raise NotImplemented() - - -@register_backend(DataSourceTypeChoices.LOCAL) +@register_data_backend() class LocalBackend(DataBackend): + name = 'local' + label = _('Local') + is_local = True @contextmanager def fetch(self): @@ -74,8 +37,10 @@ class LocalBackend(DataBackend): yield local_path -@register_backend(DataSourceTypeChoices.GIT) +@register_data_backend() class GitBackend(DataBackend): + name = 'git' + label = 'Git' parameters = { 'username': forms.CharField( required=False, @@ -144,8 +109,10 @@ class GitBackend(DataBackend): local_path.cleanup() -@register_backend(DataSourceTypeChoices.AMAZON_S3) +@register_data_backend() class S3Backend(DataBackend): + name = 'amazon-s3' + label = 'Amazon S3' parameters = { 'aws_access_key_id': forms.CharField( label=_('AWS access key ID'), diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 62a58086a..410e2e80c 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext as _ import django_filters from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet +from netbox.utils import get_data_backend_choices from .choices import * from .models import * @@ -16,7 +17,7 @@ __all__ = ( class DataSourceFilterSet(NetBoxModelFilterSet): type = django_filters.MultipleChoiceFilter( - choices=DataSourceTypeChoices, + choices=get_data_backend_choices, null_value=None ) status = django_filters.MultipleChoiceFilter( diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index a4ecd646f..dcc92c6f0 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -1,10 +1,9 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from core.choices import DataSourceTypeChoices from core.models import * from netbox.forms import NetBoxModelBulkEditForm -from utilities.forms import add_blank_choice +from netbox.utils import get_data_backend_choices from utilities.forms.fields import CommentField from utilities.forms.widgets import BulkEditNullBooleanSelect @@ -16,9 +15,8 @@ __all__ = ( class DataSourceBulkEditForm(NetBoxModelBulkEditForm): type = forms.ChoiceField( label=_('Type'), - choices=add_blank_choice(DataSourceTypeChoices), - required=False, - initial='' + choices=get_data_backend_choices, + required=False ) enabled = forms.NullBooleanField( required=False, diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index f7a6f3595..4d0acbb77 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -8,6 +8,7 @@ from core.models import * from extras.forms.mixins import SavedFiltersMixin from extras.utils import FeatureQuery from netbox.forms import NetBoxModelFilterSetForm +from netbox.utils import get_data_backend_choices from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField from utilities.forms.widgets import APISelectMultiple, DateTimePicker @@ -27,7 +28,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): ) type = forms.MultipleChoiceField( label=_('Type'), - choices=DataSourceTypeChoices, + choices=get_data_backend_choices, required=False ) status = forms.MultipleChoiceField( diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index 01d5474c6..e3184acf6 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -7,6 +7,7 @@ from core.forms.mixins import SyncedDataMixin from core.models import * from netbox.forms import NetBoxModelForm from netbox.registry import registry +from netbox.utils import get_data_backend_choices from utilities.forms import get_field_value from utilities.forms.fields import CommentField from utilities.forms.widgets import HTMXSelect @@ -18,6 +19,10 @@ __all__ = ( class DataSourceForm(NetBoxModelForm): + type = forms.ChoiceField( + choices=get_data_backend_choices, + widget=HTMXSelect() + ) comments = CommentField() class Meta: @@ -26,7 +31,6 @@ class DataSourceForm(NetBoxModelForm): 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags', ] widgets = { - 'type': HTMXSelect(), 'ignore_rules': forms.Textarea( attrs={ 'rows': 5, @@ -56,12 +60,13 @@ class DataSourceForm(NetBoxModelForm): # Add backend-specific form fields self.backend_fields = [] - for name, form_field in backend.parameters.items(): - field_name = f'backend_{name}' - self.backend_fields.append(field_name) - self.fields[field_name] = copy.copy(form_field) - if self.instance and self.instance.parameters: - self.fields[field_name].initial = self.instance.parameters.get(name) + if backend: + for name, form_field in backend.parameters.items(): + field_name = f'backend_{name}' + self.backend_fields.append(field_name) + self.fields[field_name] = copy.copy(form_field) + if self.instance and self.instance.parameters: + self.fields[field_name].initial = self.instance.parameters.get(name) def save(self, *args, **kwargs): diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index d25981920..32b546b20 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -25,7 +25,7 @@ def sync_datasource(job, *args, **kwargs): job.terminate() except Exception as e: - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) if type(e) in (SyncError, JobTimeoutException): logging.error(e) diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py new file mode 100644 index 000000000..0ad8d8854 --- /dev/null +++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-10-20 17:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_job_created_auto_now'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='type', + field=models.CharField(max_length=50), + ), + ] diff --git a/netbox/core/migrations/0007_job_add_error_field.py b/netbox/core/migrations/0007_job_add_error_field.py new file mode 100644 index 000000000..e2e173bfd --- /dev/null +++ b/netbox/core/migrations/0007_job_add_error_field.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-10-23 20:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_datasource_type_remove_choices'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='error', + field=models.TextField(blank=True, editable=False), + ), + ] diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 54a43c7ef..fb764134a 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -45,9 +45,7 @@ class DataSource(JobsMixin, PrimaryModel): ) type = models.CharField( verbose_name=_('type'), - max_length=50, - choices=DataSourceTypeChoices, - default=DataSourceTypeChoices.LOCAL + max_length=50 ) source_url = models.CharField( max_length=200, @@ -96,8 +94,9 @@ class DataSource(JobsMixin, PrimaryModel): def docs_url(self): return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' - def get_type_color(self): - return DataSourceTypeChoices.colors.get(self.type) + def get_type_display(self): + if backend := registry['data_backends'].get(self.type): + return backend.label def get_status_color(self): return DataSourceStatusChoices.colors.get(self.status) @@ -110,10 +109,6 @@ class DataSource(JobsMixin, PrimaryModel): def backend_class(self): return registry['data_backends'].get(self.type) - @property - def is_local(self): - return self.type == DataSourceTypeChoices.LOCAL - @property def ready_for_sync(self): return self.enabled and self.status not in ( @@ -123,8 +118,14 @@ class DataSource(JobsMixin, PrimaryModel): def clean(self): + # Validate data backend type + if self.type and self.type not in registry['data_backends']: + raise ValidationError({ + 'type': _("Unknown backend type: {type}".format(type=self.type)) + }) + # Ensure URL scheme matches selected type - if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''): + if self.backend_class.is_local and self.url_scheme not in ('file', ''): raise ValidationError({ 'source_url': f"URLs for local sources must start with file:// (or specify no scheme)" }) diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 61b0e64fa..4e9a93bfb 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -92,6 +92,11 @@ class Job(models.Model): null=True, blank=True ) + error = models.TextField( + verbose_name=_('error'), + editable=False, + blank=True + ) job_id = models.UUIDField( verbose_name=_('job ID'), unique=True @@ -158,7 +163,7 @@ class Job(models.Model): # Handle webhooks self.trigger_webhooks(event=EVENT_JOB_START) - def terminate(self, status=JobStatusChoices.STATUS_COMPLETED): + def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None): """ Mark the job as completed, optionally specifying a particular termination status. """ @@ -168,6 +173,8 @@ class Job(models.Model): # Mark the job as completed self.status = status + if error: + self.error = error self.completed = timezone.now() self.save() diff --git a/netbox/core/tables/columns.py b/netbox/core/tables/columns.py new file mode 100644 index 000000000..93f1e3901 --- /dev/null +++ b/netbox/core/tables/columns.py @@ -0,0 +1,20 @@ +import django_tables2 as tables + +from netbox.registry import registry + +__all__ = ( + 'BackendTypeColumn', +) + + +class BackendTypeColumn(tables.Column): + """ + Display a data backend type. + """ + def render(self, value): + if backend := registry['data_backends'].get(value): + return backend.label + return value + + def value(self, value): + return value diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py index 1ecc42369..4059ea9bc 100644 --- a/netbox/core/tables/data.py +++ b/netbox/core/tables/data.py @@ -3,6 +3,7 @@ import django_tables2 as tables from core.models import * from netbox.tables import NetBoxTable, columns +from .columns import BackendTypeColumn __all__ = ( 'DataFileTable', @@ -15,8 +16,8 @@ class DataSourceTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - type = columns.ChoiceFieldColumn( - verbose_name=_('Type'), + type = BackendTypeColumn( + verbose_name=_('Type') ) status = columns.ChoiceFieldColumn( verbose_name=_('Status'), @@ -34,8 +35,8 @@ class DataSourceTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = DataSource fields = ( - 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created', - 'last_updated', 'file_count', + 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', + 'created', 'last_updated', 'file_count', ) default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count') diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 32ca67f7f..3388aee19 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -47,7 +47,7 @@ class JobTable(NetBoxTable): model = Job fields = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started', - 'completed', 'user', 'job_id', + 'completed', 'user', 'error', 'job_id', ) default_columns = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index dc6d6a5ce..cd25761f0 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -2,7 +2,6 @@ from django.urls import reverse from django.utils import timezone from utilities.testing import APITestCase, APIViewTestCases -from ..choices import * from ..models import * @@ -26,26 +25,26 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): data_sources = ( - DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), - DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'), - DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'), + DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'), + DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'), + DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'), ) DataSource.objects.bulk_create(data_sources) cls.create_data = [ { 'name': 'Data Source 4', - 'type': DataSourceTypeChoices.GIT, + 'type': 'git', 'source_url': 'https://example.com/git/source4' }, { 'name': 'Data Source 5', - 'type': DataSourceTypeChoices.GIT, + 'type': 'git', 'source_url': 'https://example.com/git/source5' }, { 'name': 'Data Source 6', - 'type': DataSourceTypeChoices.GIT, + 'type': 'git', 'source_url': 'https://example.com/git/source6' }, ] @@ -63,7 +62,7 @@ class DataFileTest( def setUpTestData(cls): datasource = DataSource.objects.create( name='Data Source 1', - type=DataSourceTypeChoices.LOCAL, + type='local', source_url='file:///var/tmp/source1/' ) diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index e1e916f70..2f60c7522 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -18,21 +18,21 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): data_sources = ( DataSource( name='Data Source 1', - type=DataSourceTypeChoices.LOCAL, + type='local', source_url='file:///var/tmp/source1/', status=DataSourceStatusChoices.NEW, enabled=True ), DataSource( name='Data Source 2', - type=DataSourceTypeChoices.LOCAL, + type='local', source_url='file:///var/tmp/source2/', status=DataSourceStatusChoices.SYNCING, enabled=True ), DataSource( name='Data Source 3', - type=DataSourceTypeChoices.GIT, + type='git', source_url='https://example.com/git/source3', status=DataSourceStatusChoices.COMPLETED, enabled=False @@ -45,7 +45,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - params = {'type': [DataSourceTypeChoices.LOCAL]} + params = {'type': ['local']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_enabled(self): @@ -66,9 +66,9 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): data_sources = ( - DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), - DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'), - DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'), + DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'), + DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'), + DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'), ) DataSource.objects.bulk_create(data_sources) diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 4a50a8d05..16d07f376 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -1,7 +1,6 @@ from django.utils import timezone from utilities.testing import ViewTestCases, create_tags -from ..choices import * from ..models import * @@ -11,9 +10,9 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): data_sources = ( - DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), - DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'), - DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'), + DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'), + DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'), + DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'), ) DataSource.objects.bulk_create(data_sources) @@ -21,7 +20,7 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'name': 'Data Source X', - 'type': DataSourceTypeChoices.GIT, + 'type': 'git', 'source_url': 'http:///exmaple/com/foo/bar/', 'description': 'Something', 'comments': 'Foo bar baz', @@ -29,10 +28,10 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - f"name,type,source_url,enabled", - f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true", - f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true", - f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false", + "name,type,source_url,enabled", + "Data Source 4,local,file:///var/tmp/source4/,true", + "Data Source 5,local,file:///var/tmp/source4/,true", + "Data Source 6,git,http:///exmaple/com/foo/bar/,false", ) cls.csv_update_data = ( @@ -60,7 +59,7 @@ class DataFileTestCase( def setUpTestData(cls): datasource = DataSource.objects.create( name='Data Source 1', - type=DataSourceTypeChoices.LOCAL, + type='local', source_url='file:///var/tmp/source1/' ) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b43611dad..32dcdc5bb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer): model = DeviceType fields = [ 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', - 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', - 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', - 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count', + 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', + 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count', 'interface_template_count', 'front_port_template_count', 'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count', diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 80a991736..a3e532f0b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -24,6 +24,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model +from utilities.query_functions import CollateAsChar from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers @@ -505,6 +506,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): filterset_class = filtersets.InterfaceFilterSet brief_prefetch_fields = ['device'] + def get_bulk_destroy_queryset(self): + # Ensure child interfaces are deleted prior to their parents + return self.get_queryset().order_by('device', 'parent', CollateAsChar('_name')) + class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = FrontPort.objects.prefetch_related( diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index e1d4a330a..2ba24e0aa 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -80,10 +80,10 @@ class RackWidthChoices(ChoiceSet): WIDTH_23IN = 23 CHOICES = ( - (WIDTH_10IN, _('10 inches')), - (WIDTH_19IN, _('19 inches')), - (WIDTH_21IN, _('21 inches')), - (WIDTH_23IN, _('23 inches')), + (WIDTH_10IN, _('{n} inches').format(n=10)), + (WIDTH_19IN, _('{n} inches').format(n=19)), + (WIDTH_21IN, _('{n} inches').format(n=21)), + (WIDTH_23IN, _('{n} inches').format(n=23)), ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d600667d7..c65110d9a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -496,7 +496,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType fields = [ - 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', + 'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', + 'airflow', 'weight', 'weight_unit', ] def search(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index cacf1f72b..9c64d8a19 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -420,6 +420,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): widget=BulkEditNullBooleanSelect(), label=_('Is full depth') ) + exclude_from_utilization = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label=_('Exclude from utilization') + ) airflow = forms.ChoiceField( label=_('Airflow'), choices=add_blank_choice(DeviceAirflowChoices), @@ -445,7 +450,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): model = DeviceType fieldsets = ( - (_('Device Type'), ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')), + (_('Device Type'), ( + 'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', + 'airflow', 'description', + )), (_('Weight'), ('weight', 'weight_unit')), ) nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index e41e875e4..d63873b59 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -335,8 +335,8 @@ class DeviceTypeImportForm(NetBoxModelImportForm): class Meta: model = DeviceType fields = [ - 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags', + 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', + 'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags', ] diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 77543af12..3be4d08e8 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -116,17 +116,17 @@ class ModuleCommonForm(forms.Form): # It is not possible to adopt components already belonging to a module if adopt_components and existing_item and existing_item.module: raise forms.ValidationError( - _("Cannot adopt {name} '{resolved_name}' as it already belongs to a module").format( - name=template.component_model.__name__, - resolved_name=resolved_name + _("Cannot adopt {model} {name} as it already belongs to a module").format( + model=template.component_model.__name__, + name=resolved_name ) ) # If we are not adopting components we error if the component exists if not adopt_components and resolved_name in installed_components: raise forms.ValidationError( - _("{name} - {resolved_name} already exists").format( - name=template.component_model.__name__, - resolved_name=resolved_name + _("A {model} named {name} already exists").format( + model=template.component_model.__name__, + name=resolved_name ) ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 93e214598..3d626d201 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm): fieldsets = ( (_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')), (_('Chassis'), ( - 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit', + 'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', + 'weight', 'weight_unit', )), (_('Images'), ('front_image', 'rear_image')), ) @@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm): class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', - 'comments', 'tags', + 'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', + 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', + 'description', 'comments', 'tags', ] widgets = { 'front_image': ClearableFileInput(attrs={ diff --git a/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py b/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py new file mode 100644 index 000000000..6943387c5 --- /dev/null +++ b/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2023-10-20 22:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0181_rename_device_role_device_role'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='exclude_from_utilization', + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox/dcim/migrations/0183_protect_child_interfaces.py b/netbox/dcim/migrations/0183_protect_child_interfaces.py new file mode 100644 index 000000000..ca695f4bd --- /dev/null +++ b/netbox/dcim/migrations/0183_protect_child_interfaces.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.6 on 2023-10-20 11:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0182_devicetype_exclude_from_utilization'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='dcim.interface'), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 86b6d85fe..5110835f4 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -534,14 +534,16 @@ class FrontPortTemplate(ModularComponentTemplateModel): # Validate rear port assignment if self.rear_port.device_type != self.device_type: raise ValidationError( - _("Rear port ({}) must belong to the same device type").format(self.rear_port) + _("Rear port ({name}) must belong to the same device type").format(name=self.rear_port) ) # Validate rear port position assignment if self.rear_port_position > self.rear_port.positions: raise ValidationError( - _("Invalid rear port position ({}); rear port {} has only {} positions").format( - self.rear_port_position, self.rear_port.name, self.rear_port.positions + _("Invalid rear port position ({position}); rear port {name} has only {count} positions").format( + position=self.rear_port_position, + name=self.rear_port.name, + count=self.rear_port.positions ) ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 639f8aadb..94568459e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -537,7 +537,7 @@ class BaseInterface(models.Model): ) parent = models.ForeignKey( to='self', - on_delete=models.SET_NULL, + on_delete=models.RESTRICT, related_name='child_interfaces', null=True, blank=True, diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index c9ebf898d..07c1c70f6 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -106,6 +106,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): default=1.0, verbose_name=_('height (U)') ) + exclude_from_utilization = models.BooleanField( + default=False, + verbose_name=_('exclude from utilization'), + help_text=_('Exclude from rack utilization calculations.') + ) is_full_depth = models.BooleanField( default=True, verbose_name=_('is full depth'), @@ -297,8 +302,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): ) if d.position not in u_available: raise ValidationError({ - 'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of " - "{}U").format(d, d.rack, self.u_height) + 'u_height': _( + "Device {device} in rack {rack} does not have sufficient space to accommodate a " + "height of {height}U" + ).format(device=d, rack=d.rack, height=self.u_height) }) # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position. @@ -915,7 +922,7 @@ class Device( if self.primary_ip4: if self.primary_ip4.family != 4: raise ValidationError({ - 'primary_ip4': _("{primary_ip4} is not an IPv4 address.").format(primary_ip4=self.primary_ip4) + 'primary_ip4': _("{ip} is not an IPv4 address.").format(ip=self.primary_ip4) }) if self.primary_ip4.assigned_object in vc_interfaces: pass @@ -924,13 +931,13 @@ class Device( else: raise ValidationError({ 'primary_ip4': _( - "The specified IP address ({primary_ip4}) is not assigned to this device." - ).format(primary_ip4=self.primary_ip4) + "The specified IP address ({ip}) is not assigned to this device." + ).format(ip=self.primary_ip4) }) if self.primary_ip6: if self.primary_ip6.family != 6: raise ValidationError({ - 'primary_ip6': _("{primary_ip6} is not an IPv6 address.").format(primary_ip6=self.primary_ip6m) + 'primary_ip6': _("{ip} is not an IPv6 address.").format(ip=self.primary_ip6) }) if self.primary_ip6.assigned_object in vc_interfaces: pass @@ -939,8 +946,8 @@ class Device( else: raise ValidationError({ 'primary_ip6': _( - "The specified IP address ({primary_ip6}) is not assigned to this device." - ).format(primary_ip6=self.primary_ip6) + "The specified IP address ({ip}) is not assigned to this device." + ).format(ip=self.primary_ip6) }) if self.oob_ip: if self.oob_ip.assigned_object in vc_interfaces: @@ -958,17 +965,19 @@ class Device( raise ValidationError({ 'platform': _( "The assigned platform is limited to {platform_manufacturer} device types, but this device's " - "type belongs to {device_type_manufacturer}." + "type belongs to {devicetype_manufacturer}." ).format( platform_manufacturer=self.platform.manufacturer, - device_type_manufacturer=self.device_type.manufacturer + devicetype_manufacturer=self.device_type.manufacturer ) }) # A Device can only be assigned to a Cluster in the same Site (or no Site) if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: raise ValidationError({ - 'cluster': _("The assigned cluster belongs to a different site ({})").format(self.cluster.site) + 'cluster': _("The assigned cluster belongs to a different site ({site})").format( + site=self.cluster.site + ) }) # Validate virtual chassis assignment @@ -1440,8 +1449,8 @@ class VirtualDeviceContext(PrimaryModel): if primary_ip.family != family: raise ValidationError({ f'primary_ip{family}': _( - "{primary_ip} is not an IPv{family} address." - ).format(family=family, primary_ip=primary_ip) + "{ip} is not an IPv{family} address." + ).format(family=family, ip=primary_ip) }) device_interfaces = self.device.vc_interfaces(if_master=False) if primary_ip.assigned_object not in device_interfaces: diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index ef0dde4da..0d4b844f9 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -357,7 +357,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): return [u for u in elevation.values()] - def get_available_units(self, u_height=1, rack_face=None, exclude=None): + def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False): """ Return a list of units within the rack available to accommodate a device of a given U height (default 1). Optionally exclude one or more devices when calculating empty units (needed when moving a device from one @@ -366,9 +366,13 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): :param u_height: Minimum number of contiguous free units required :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth :param exclude: List of devices IDs to exclude (useful when moving a device within a rack) + :param ignore_excluded_devices: Ignore devices that are marked to exclude from utilization calculations """ # Gather all devices which consume U space within the rack devices = self.devices.prefetch_related('device_type').filter(position__gte=1) + if ignore_excluded_devices: + devices = devices.exclude(device_type__exclude_from_utilization=True) + if exclude is not None: devices = devices.exclude(pk__in=exclude) @@ -453,7 +457,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): """ # Determine unoccupied units total_units = len(list(self.units)) - available_units = self.get_available_units(u_height=0.5) + available_units = self.get_available_units(u_height=0.5, ignore_excluded_devices=True) # Remove reserved units for ru in self.get_reserved_units(): @@ -558,9 +562,9 @@ class RackReservation(PrimaryModel): invalid_units = [u for u in self.units if u not in self.rack.units] if invalid_units: raise ValidationError({ - 'units': _("Invalid unit(s) for {}U rack: {}").format( - self.rack.u_height, - ', '.join([str(u) for u in invalid_units]), + 'units': _("Invalid unit(s) for {height}U rack: {unit_list}").format( + height=self.rack.u_height, + unit_list=', '.join([str(u) for u in invalid_units]) ), }) @@ -571,8 +575,8 @@ class RackReservation(PrimaryModel): conflicting_units = [u for u in self.units if u in reserved_units] if conflicting_units: raise ValidationError({ - 'units': _('The following units have already been reserved: {}').format( - ', '.join([str(u) for u in conflicting_units]), + 'units': _('The following units have already been reserved: {unit_list}').format( + unit_list=', '.join([str(u) for u in conflicting_units]) ) }) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index acc4fcad9..31e090078 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -159,6 +159,7 @@ class CableTraceSVG: labels.append(location_label) elif instance._meta.model_name == 'circuit': labels[0] = f'Circuit {instance}' + labels.append(instance.type) labels.append(instance.provider) if instance.description: labels.append(instance.description) @@ -181,6 +182,8 @@ class CableTraceSVG: if hasattr(instance, 'role'): # Device return instance.role.color + elif instance._meta.model_name == 'circuit' and instance.type.color: + return instance.type.color else: # Other parent object return 'e0e0e0' diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 7d8884fc1..fad238c6e 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -98,6 +98,7 @@ class DeviceTypeTable(NetBoxTable): verbose_name=_('U Height'), template_code='{{ value|floatformat }}' ) + exclude_from_utilization = columns.BooleanColumn() weight = columns.TemplateColumn( verbose_name=_('Weight'), template_code=WEIGHT, @@ -142,9 +143,9 @@ class DeviceTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.DeviceType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', - 'last_updated', + 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', + 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', + 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1ce362963..d3211a75f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1607,6 +1607,33 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, ] + def test_bulk_delete_child_interfaces(self): + interface1 = Interface.objects.get(name='Interface 1') + device = interface1.device + self.add_permissions('dcim.delete_interface') + + # Create a child interface + child = Interface.objects.create( + device=device, + name='Interface 1A', + type=InterfaceTypeChoices.TYPE_VIRTUAL, + parent=interface1 + ) + self.assertEqual(device.interfaces.count(), 4) + + # Attempt to delete only the parent interface + url = self._get_detail_url(interface1) + self.client.delete(url, **self.header) + self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = [ + {"id": interface1.pk}, + {"id": child.pk}, + ] + self.client.delete(self._get_list_url(), data, format='json', **self.header) + self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted + class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2e5ae0d5c..741a615d4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -238,6 +238,40 @@ class RackTestCase(TestCase): # Check that Device1 is now assigned to Site B self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b) + def test_utilization(self): + site = Site.objects.first() + rack = Rack.objects.first() + + Device( + name='Device 1', + role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack, + position=1 + ).save() + rack.refresh_from_db() + self.assertEqual(rack.get_utilization(), 1 / 42 * 100) + + # create device excluded from utilization calculations + dt = DeviceType.objects.create( + manufacturer=Manufacturer.objects.first(), + model='Device Type 4', + slug='device-type-4', + u_height=1, + exclude_from_utilization=True + ) + Device( + name='Device 2', + role=DeviceRole.objects.first(), + device_type=dt, + site=site, + rack=rack, + position=5 + ).save() + rack.refresh_from_db() + self.assertEqual(rack.get_utilization(), 1 / 42 * 100) + class DeviceTestCase(TestCase): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a6981451f..88e0d44f2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2531,6 +2531,36 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) self.assertHttpStatus(response, 200) + def test_bulk_delete_child_interfaces(self): + interface1 = Interface.objects.get(name='Interface 1') + device = interface1.device + self.add_permissions('dcim.delete_interface') + + # Create a child interface + child = Interface.objects.create( + device=device, + name='Interface 1A', + type=InterfaceTypeChoices.TYPE_VIRTUAL, + parent=interface1 + ) + self.assertEqual(device.interfaces.count(), 6) + + # Attempt to delete only the parent interface + data = { + 'confirm': True, + } + self.client.post(self._get_url('delete', interface1), data) + self.assertEqual(device.interfaces.count(), 6) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = { + 'pk': [interface1.pk, child.pk], + 'confirm': True, + '_confirm': True, # Form button + } + self.client.post(self._get_url('bulk_delete'), data) + self.assertEqual(device.interfaces.count(), 4) # Child & parent were both deleted + class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0f5768173..be0d6bcbb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,4 @@ import traceback -from collections import defaultdict from django.contrib import messages from django.contrib.contenttypes.models import ContentType @@ -26,6 +25,7 @@ from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.query_functions import CollateAsChar from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from virtualization.models import VirtualMachine @@ -2562,7 +2562,8 @@ class InterfaceBulkDisconnectView(BulkDisconnectView): class InterfaceBulkDeleteView(generic.BulkDeleteView): - queryset = Interface.objects.all() + # Ensure child interfaces are deleted prior to their parents + queryset = Interface.objects.order_by('device', 'parent', CollateAsChar('_name')) filterset = filtersets.InterfaceFilterSet table = tables.InterfaceTable diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 83a346420..fd2ce8f2d 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -491,7 +491,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe (_('Security'), ('ALLOWED_URL_SCHEMES',)), (_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), (_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), - (_('Validation'), ('CUSTOM_VALIDATORS',)), + (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')), (_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)), (_('Miscellaneous'), ( '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_BOTTOM': 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(), } diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index d9a9f41ae..3cf70281c 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -59,7 +59,7 @@ class Command(BaseCommand): logger.error(f"Exception raised during script execution: {e}") clear_webhooks.send(request) job.data = ScriptOutputSerializer(script).data - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) logger.info(f"Script completed in {job.duration}") diff --git a/netbox/extras/migrations/0099_cachedvalue_ordering.py b/netbox/extras/migrations/0099_cachedvalue_ordering.py new file mode 100644 index 000000000..242ffd983 --- /dev/null +++ b/netbox/extras/migrations/0099_cachedvalue_ordering.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.6 on 2023-10-30 14:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cachedvalue', + options={'ordering': ('weight', 'object_type', 'value', 'object_id')}, + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 2bed464bb..2cb12ed5b 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -287,8 +287,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): except ValidationError as err: raise ValidationError({ 'default': _( - 'Invalid default value "{default}": {message}' - ).format(default=self.default, message=err.message) + 'Invalid default value "{value}": {error}' + ).format(value=self.default, error=err.message) }) # Minimum/maximum values can be set only for numeric fields @@ -332,8 +332,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): elif self.object_type: raise ValidationError({ 'object_type': _( - "{type_display} fields may not define an object type.") - .format(type_display=self.get_type_display()) + "{type} fields may not define an object type.") + .format(type=self.get_type_display()) }) def serialize(self, value): diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index debe4c648..39ff80215 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -50,7 +50,7 @@ class CachedValue(models.Model): ) class Meta: - ordering = ('weight', 'object_type', 'object_id') + ordering = ('weight', 'object_type', 'value', 'object_id') verbose_name = _('cached value') verbose_name_plural = _('cached values') diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index cc279a49a..c8a13fe15 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -40,8 +40,8 @@ def run_report(job, *args, **kwargs): try: report.run(job) - except Exception: - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + except Exception as e: + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) logging.error(f"Error during execution of report {job.name}") finally: # Schedule the next job if an interval has been set @@ -230,7 +230,7 @@ class Report(object): stacktrace = traceback.format_exc() self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e}
{stacktrace}
") logger.error(f"Exception raised during report execution: {e}") - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) # Perform any post-run tasks self.post_run() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index e93326ddc..df75200e6 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -519,7 +519,7 @@ def run_script(data, request, job, commit=True, **kwargs): logger.error(f"Exception raised during script execution: {e}") script.log_info("Database changes have been reverted due to error.") job.data = ScriptOutputSerializer(script).data - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) clear_webhooks.send(request) logger.info(f"Script completed in {job.duration}") diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index d6550309f..8bdaf523c 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -2,8 +2,10 @@ import importlib import logging 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.dispatch import receiver, Signal +from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates from extras.validators import CustomValidator @@ -178,11 +180,7 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type # Custom validation # -@receiver(post_clean) -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, []) +def run_validators(instance, validators): for validator in validators: @@ -198,6 +196,29 @@ def run_custom_validators(sender, instance, **kwargs): 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 # diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidation.py similarity index 64% rename from netbox/extras/tests/test_customvalidator.py rename to netbox/extras/tests/test_customvalidation.py index 0fe507b67..d74ad599b 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidation.py @@ -1,10 +1,13 @@ from django.conf import settings from django.core.exceptions import ValidationError +from django.db import transaction from django.test import TestCase, override_settings from ipam.models import ASN, RIR +from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.validators import CustomValidator +from utilities.exceptions import AbortRequest class MyValidator(CustomValidator): @@ -14,6 +17,20 @@ class MyValidator(CustomValidator): self.fail("Name must be foo!") +eq_validator = CustomValidator({ + 'asn': { + 'eq': 100 + } +}) + + +neq_validator = CustomValidator({ + 'asn': { + 'neq': 100 + } +}) + + min_validator = CustomValidator({ 'asn': { 'min': 65000 @@ -77,6 +94,18 @@ class CustomValidatorTest(TestCase): validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0] 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]}) def test_min(self): with self.assertRaises(ValidationError): @@ -147,7 +176,7 @@ class CustomValidatorConfigTest(TestCase): @override_settings( CUSTOM_VALIDATORS={ '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() with self.assertRaises(ValidationError): 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() diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index 686c9b032..98b4fd88d 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,15 +1,38 @@ -from django.core.exceptions import ValidationError 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 # 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: """ Employed by CustomValidator to enforce required fields. """ - message = "This field must be empty." + message = _("This field must be empty.") code = 'is_empty' def __init__(self, enforce=True): @@ -24,7 +47,7 @@ class IsNotEmptyValidator: """ Employed by CustomValidator to enforce prohibited fields. """ - message = "This field must not be empty." + message = _("This field must not be empty.") code = 'not_empty' def __init__(self, enforce=True): @@ -50,6 +73,8 @@ class CustomValidator: :param validation_rules: A dictionary mapping object attributes to validation rules """ VALIDATORS = { + 'eq': IsEqualValidator, + 'neq': IsNotEqualValidator, 'min': validators.MinValueValidator, 'max': validators.MaxValueValidator, 'min_length': validators.MinLengthValidator, diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index bfd4f952d..dd9e6b3e4 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -372,14 +372,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): # Do not allow assigning a network ID or broadcast address to an interface. if interface and (address := self.cleaned_data.get('address')): if address.ip == address.network: - msg = _("{address} is a network ID, which may not be assigned to an interface.").format(address=address) + msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip) if address.version == 4 and address.prefixlen not in (31, 32): raise ValidationError(msg) if address.version == 6 and address.prefixlen not in (127, 128): raise ValidationError(msg) if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): - msg = _("{address} is a broadcast address, which may not be assigned to an interface.").format( - address=address + msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format( + ip=address.ip ) raise ValidationError(msg) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index d176d3bff..934cb98c7 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -140,8 +140,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): if covering_aggregates: raise ValidationError({ 'prefix': _( - "Aggregates cannot overlap. {} is already covered by an existing aggregate ({})." - ).format(self.prefix, covering_aggregates[0]) + "Aggregates cannot overlap. {prefix} is already covered by an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covering_aggregates[0] + ) }) # Ensure that the aggregate being added does not cover an existing aggregate @@ -150,8 +153,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: raise ValidationError({ - 'prefix': _("Aggregates cannot overlap. {} covers an existing aggregate ({}).").format( - self.prefix, covered_aggregates[0] + 'prefix': _( + "Prefixes cannot overlap aggregates. {prefix} covers an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covered_aggregates[0] ) }) @@ -314,10 +320,11 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: + table = _("VRF {vrf}").format(vrf=self.vrf) if self.vrf else _("global table") raise ValidationError({ - 'prefix': _("Duplicate prefix found in {}: {}").format( - _("VRF {}").format(self.vrf) if self.vrf else _("global table"), - duplicate_prefixes.first(), + 'prefix': _("Duplicate prefix found in {table}: {prefix}").format( + table=table, + prefix=duplicate_prefixes.first(), ) }) @@ -843,10 +850,11 @@ class IPAddress(PrimaryModel): self.role not in IPADDRESS_ROLES_NONUNIQUE or any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips) ): + table = _("VRF {vrf}").format(vrf=self.vrf) if self.vrf else _("global table") raise ValidationError({ - 'address': _("Duplicate IP address found in {}: {}").format( - _("VRF {}").format(self.vrf) if self.vrf else _("global table"), - duplicate_ips.first(), + 'address': _("Duplicate IP address found in {table}: {ipaddress}").format( + table=table, + ipaddress=duplicate_ips.first(), ) }) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index aa5b36a57..675d03ee5 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -234,8 +234,8 @@ class VLAN(PrimaryModel): if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: raise ValidationError({ 'vid': _( - "VID must be between {min_vid} and {max_vid} for VLANs in group {group}" - ).format(min_vid=self.group.min_vid, max_vid=self.group.max_vid, group=self.group) + "VID must be between {minimum} and {maximum} for VLANs in group {group}" + ).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group) }) def get_status_color(self): diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index c6794bb61..522bcf77b 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -2,7 +2,7 @@ import logging from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction -from django.db.models import ProtectedError +from django.db.models import ProtectedError, RestrictedError from django_pglocks import advisory_lock from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins @@ -91,8 +91,11 @@ class NetBoxModelViewSet( try: return super().dispatch(request, *args, **kwargs) - except ProtectedError as e: - protected_objects = list(e.protected_objects) + except (ProtectedError, RestrictedError) as e: + if type(e) is ProtectedError: + protected_objects = list(e.protected_objects) + else: + protected_objects = list(e.restricted_objects) msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) logger.warning(msg) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index fde486fe9..7b6c00843 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -137,11 +137,14 @@ class BulkUpdateModelMixin: } ] """ + def get_bulk_update_queryset(self): + return self.get_queryset() + def bulk_update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_update_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) @@ -184,10 +187,13 @@ class BulkDestroyModelMixin: {"id": 456} ] """ + def get_bulk_destroy_queryset(self): + return self.get_queryset() + def bulk_destroy(self, request, *args, **kwargs): serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_destroy_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 31c4f693a..0cdf8a8d2 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -152,9 +152,17 @@ PARAMS = ( description=_("Custom validation rules (JSON)"), field=forms.JSONField, field_kwargs={ - 'widget': forms.Textarea( - attrs={'class': 'vLargeTextField'} - ), + 'widget': forms.Textarea(), + }, + ), + ConfigParam( + name='PROTECTION_RULES', + label=_('Protection rules'), + default={}, + description=_("Deletion protection rules (JSON)"), + field=forms.JSONField, + field_kwargs={ + 'widget': forms.Textarea(), }, ), diff --git a/netbox/netbox/data_backends.py b/netbox/netbox/data_backends.py new file mode 100644 index 000000000..d5bab75c1 --- /dev/null +++ b/netbox/netbox/data_backends.py @@ -0,0 +1,53 @@ +from contextlib import contextmanager +from urllib.parse import urlparse + +__all__ = ( + 'DataBackend', +) + + +class DataBackend: + """ + A data backend represents a specific system of record for data, such as a git repository or Amazon S3 bucket. + + Attributes: + name: The identifier under which this backend will be registered in NetBox + label: The human-friendly name for this backend + is_local: A boolean indicating whether this backend accesses local data + parameters: A dictionary mapping configuration form field names to their classes + sensitive_parameters: An iterable of field names for which the values should not be displayed to the user + """ + is_local = False + parameters = {} + sensitive_parameters = [] + + # Prevent Django's template engine from calling the backend + # class when referenced via DataSource.backend_class + do_not_call_in_templates = True + + def __init__(self, url, **kwargs): + self.url = url + self.params = kwargs + self.config = self.init_config() + + def init_config(self): + """ + A hook to initialize the instance's configuration. The data returned by this method is assigned to the + instance's `config` attribute upon initialization, which can be referenced by the `fetch()` method. + """ + return + + @property + def url_scheme(self): + return urlparse(self.url).scheme.lower() + + @contextmanager + def fetch(self): + """ + A context manager which performs the following: + + 1. Handles all setup and synchronization + 2. Yields the local path at which data has been replicated + 3. Performs any necessary cleanup + """ + raise NotImplemented() diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index f111aa160..43cf3f869 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,4 +1,4 @@ -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.registry import registry from utilities.choices import ButtonColorChoices diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index f60462f3d..8b6901b7a 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -8,6 +8,7 @@ from packaging import version from netbox.registry import registry from netbox.search import register_search +from netbox.utils import register_data_backend from .navigation import * from .registration import * from .templates import * @@ -24,6 +25,7 @@ registry['plugins'].update({ DEFAULT_RESOURCE_PATHS = { 'search_indexes': 'search.indexes', + 'data_backends': 'data_backends.backends', 'graphql_schema': 'graphql.schema', 'menu': 'navigation.menu', 'menu_items': 'navigation.menu_items', @@ -70,6 +72,7 @@ class PluginConfig(AppConfig): # Optional plugin resources search_indexes = None + data_backends = None graphql_schema = None menu = None menu_items = None @@ -98,6 +101,11 @@ class PluginConfig(AppConfig): for idx in search_indexes: register_search(idx) + # Register data backends (if defined) + data_backends = self._load_resource('data_backends') or [] + for backend in data_backends: + register_data_backend()(backend) + # Register template content (if defined) if template_extensions := self._load_resource('template_extensions'): register_template_extensions(template_extensions) diff --git a/netbox/netbox/tests/dummy_plugin/data_backends.py b/netbox/netbox/tests/dummy_plugin/data_backends.py new file mode 100644 index 000000000..9b63e51c6 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/data_backends.py @@ -0,0 +1,18 @@ +from contextlib import contextmanager + +from netbox.data_backends import DataBackend + + +class DummyBackend(DataBackend): + name = 'dummy' + label = 'Dummy' + is_local = True + + @contextmanager + def fetch(self): + yield '/tmp' + + +backends = ( + DummyBackend, +) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index f5f97013e..046436a86 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -6,6 +6,7 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from netbox.tests.dummy_plugin import config as dummy_config +from netbox.tests.dummy_plugin.data_backends import DummyBackend from netbox.plugins.navigation import PluginMenu from netbox.plugins.utils import get_plugin_config from netbox.graphql.schema import Query @@ -111,6 +112,13 @@ class PluginTest(TestCase): """ self.assertIn('netbox.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE) + def test_data_backends(self): + """ + Check registered data backends. + """ + self.assertIn('dummy', registry['data_backends']) + self.assertIs(registry['data_backends']['dummy'], DummyBackend) + def test_queues(self): """ Check that plugin queues are registered with the accurate name. diff --git a/netbox/netbox/utils.py b/netbox/netbox/utils.py new file mode 100644 index 000000000..f27d1b5f7 --- /dev/null +++ b/netbox/netbox/utils.py @@ -0,0 +1,26 @@ +from netbox.registry import registry + +__all__ = ( + 'get_data_backend_choices', + 'register_data_backend', +) + + +def get_data_backend_choices(): + return [ + (None, '---------'), + *[ + (name, cls.label) for name, cls in registry['data_backends'].items() + ] + ] + + +def register_data_backend(): + """ + Decorator for registering a DataBackend class. + """ + def _wrapper(cls): + registry['data_backends'][cls.name] = cls + return cls + + return _wrapper diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 676e3f5af..fbe3aa2ba 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -7,7 +7,7 @@ from django.contrib.contenttypes.fields import GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError -from django.db.models import ManyToManyField, ProtectedError +from django.db.models import ManyToManyField, ProtectedError, RestrictedError from django.db.models.fields.reverse_related import ManyToManyRel from django.forms import HiddenInput, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse @@ -798,14 +798,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): queryset = self.queryset.filter(pk__in=pk_list) deleted_count = queryset.count() try: - for obj in queryset: - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - obj.delete() + with transaction.atomic(): + for obj in queryset: + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + obj.delete() - except ProtectedError as e: - logger.info("Caught ProtectedError while attempting to delete objects") + except (ProtectedError, RestrictedError) as e: + logger.info(f"Caught {type(e)} while attempting to delete objects") handle_protectederror(queryset, request, e) return redirect(self.get_return_url(request)) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 99d8ff540..99508c9e3 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -1,9 +1,11 @@ import logging +from collections import defaultdict from copy import deepcopy from django.contrib import messages -from django.db import transaction -from django.db.models import ProtectedError +from django.db import router, transaction +from django.db.models import ProtectedError, RestrictedError +from django.db.models.deletion import Collector from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import escape @@ -320,6 +322,27 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') + def _get_dependent_objects(self, obj): + """ + Returns a dictionary mapping of dependent objects (organized by model) which will be deleted as a result of + deleting the requested object. + + Args: + obj: The object to return dependent objects for + """ + using = router.db_for_write(obj._meta.model) + collector = Collector(using=using) + collector.collect([obj]) + + # Compile a mapping of models to instances + dependent_objects = defaultdict(list) + for model, instance in collector.instances_with_model(): + # Omit the root object + if instance != obj: + dependent_objects[model].append(instance) + + return dict(dependent_objects) + # # Request handlers # @@ -333,6 +356,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ obj = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) + dependent_objects = self._get_dependent_objects(obj) # If this is an HTMX request, return only the rendered deletion form as modal content if is_htmx(request): @@ -343,6 +367,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'form_url': form_url, + 'dependent_objects': dependent_objects, **self.get_extra_context(request, obj), }) @@ -350,6 +375,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): 'object': obj, 'form': form, 'return_url': self.get_return_url(request, obj), + 'dependent_objects': dependent_objects, **self.get_extra_context(request, obj), }) @@ -374,8 +400,8 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): try: obj.delete() - except ProtectedError as e: - logger.info("Caught ProtectedError while attempting to delete object") + except (ProtectedError, RestrictedError) as e: + logger.info(f"Caught {type(e)} while attempting to delete objects") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index b8b08baf0..407ee4042 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -29,6 +29,16 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Color" %} + + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} + + diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html index 369c395f8..51090b0c9 100644 --- a/netbox/templates/core/datasource.html +++ b/netbox/templates/core/datasource.html @@ -58,7 +58,7 @@ {% trans "URL" %} - {% if not object.is_local %} + {% if not object.type.is_local %} {{ object.source_url }} {% else %} {{ object.source_url }} diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index 1fe3862cd..deb651739 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -35,6 +35,12 @@ {% trans "Status" %} {% badge object.get_status_display object.get_status_color %} + {% if object.error %} + + {% trans "Error" %} + {{ object.error }} + + {% endif %} {% trans "Created By" %} {{ object.user|placeholder }} diff --git a/netbox/templates/dcim/devicebay_delete.html b/netbox/templates/dcim/devicebay_delete.html index 18f4f6576..9e54baa86 100644 --- a/netbox/templates/dcim/devicebay_delete.html +++ b/netbox/templates/dcim/devicebay_delete.html @@ -8,8 +8,8 @@ {% block message %}

- {% blocktrans trimmed %} - Are you sure you want to delete this device bay from {{ devicebay.device }}? + {% blocktrans trimmed with device=devicebay.device %} + Are you sure you want to delete this device bay from {{ device }}? {% endblocktrans %}

{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 419ab7f00..35b089664 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -40,6 +40,10 @@ {% trans "Height (U" %}) {{ object.u_height|floatformat }} + + {% trans "Exclude From Utilization" %}) + {% checkmark object.exclude_from_utilization %} + {% trans "Full Depth" %} {% checkmark object.is_full_depth %} diff --git a/netbox/templates/extras/configrevision.html b/netbox/templates/extras/configrevision.html index 4f2abf30b..a880865c3 100644 --- a/netbox/templates/extras/configrevision.html +++ b/netbox/templates/extras/configrevision.html @@ -151,6 +151,10 @@ {% trans "Custom validators" %} {{ object.data.CUSTOM_VALIDATORS|placeholder }} + + {% trans "Protection rules" %} + {{ object.data.PROTECTION_RULES|placeholder }} + diff --git a/netbox/templates/htmx/delete_form.html b/netbox/templates/htmx/delete_form.html index 15f08ebfd..80aec2c82 100644 --- a/netbox/templates/htmx/delete_form.html +++ b/netbox/templates/htmx/delete_form.html @@ -12,6 +12,40 @@ Are you sure you want to delete {{ object_type }} {{ object }}? {% endblocktrans %}

+ {% if dependent_objects %} +

+ {% trans "The following objects will be deleted as a result of this action." %} +

+
+ {% for model, instances in dependent_objects.items %} +
+

+ +

+
+
+
+ {% for instance in instances %} + {% with url=instance.get_absolute_url %} + {{ instance }} + {% endwith %} + {% endfor %} +
+
+
+
+ {% endfor %} +
+ {% endif %} {% render_form form %}