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 @@
- {% 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 "The following objects will be deleted as a result of this action." %} +
+