Merge branch 'feature' into 12135-protect-child-interfaces

This commit is contained in:
Jeremy Stretch 2023-10-31 08:43:53 -04:00
commit 2d4a5e935c
119 changed files with 13781 additions and 875 deletions

View File

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

View File

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

View File

@ -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

View File

@ -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 | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `queues` | A list of custom background task queues to create | | `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`) | | `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`) | | `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`) | | `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`) | | `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |

View File

@ -136,6 +136,7 @@ nav:
- Forms: 'plugins/development/forms.md' - Forms: 'plugins/development/forms.md'
- Filters & Filter Sets: 'plugins/development/filtersets.md' - Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md' - Search: 'plugins/development/search.md'
- Data Backends: 'plugins/development/data-backends.md'
- REST API: 'plugins/development/rest-api.md' - REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md' - GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md' - Background Tasks: 'plugins/development/background-tasks.md'

View File

@ -85,7 +85,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ 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', 'circuit_count',
] ]

View File

@ -137,7 +137,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ['id', 'name', 'slug', 'description'] fields = ['id', 'name', 'slug', 'color', 'description']
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
@ -154,12 +154,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
provider_account_id = django_filters.ModelMultipleChoiceFilter( provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account', field_name='provider_account',
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
label=_('ProviderAccount (ID)'), label=_('Provider account (ID)'),
) )
provider_network_id = django_filters.ModelMultipleChoiceFilter( provider_network_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__provider_network', field_name='terminations__provider_network',
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
label=_('ProviderNetwork (ID)'), label=_('Provider network (ID)'),
) )
type_id = django_filters.ModelMultipleChoiceFilter( type_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),

View File

@ -7,7 +7,7 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import add_blank_choice 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 from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = ( __all__ = (
@ -91,6 +91,10 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
color = ColorField(
label=_('Color'),
required=False
)
description = forms.CharField( description = forms.CharField(
label=_('Description'), label=_('Description'),
max_length=200, max_length=200,
@ -99,9 +103,9 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
model = CircuitType model = CircuitType
fieldsets = ( fieldsets = (
(None, ('description',)), (None, ('color', 'description')),
) )
nullable_fields = ('description',) nullable_fields = ('color', 'description')
class CircuitBulkEditForm(NetBoxModelBulkEditForm): class CircuitBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -3,6 +3,7 @@ from django import forms
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.models import Site from dcim.models import Site
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
@ -64,7 +65,10 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ('name', 'slug', 'description', 'tags') fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class CircuitImportForm(NetBoxModelImportForm): class CircuitImportForm(NetBoxModelImportForm):

View File

@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN from ipam.models import ASN
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm 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 from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = ( __all__ = (
@ -88,7 +88,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
label=_('Provider') label=_('Provider')
) )
service_id = forms.CharField( service_id = forms.CharField(
label=_('Service id'), label=_('Service ID'),
max_length=100, max_length=100,
required=False required=False
) )
@ -97,8 +97,17 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
model = CircuitType model = CircuitType
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('color',)),
)
tag = TagFilterField(model) tag = TagFilterField(model)
color = ColorField(
label=_('Color'),
required=False
)
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Circuit model = Circuit

View File

@ -76,14 +76,14 @@ class CircuitTypeForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
(_('Circuit Type'), ( (_('Circuit Type'), (
'name', 'slug', 'description', 'tags', 'name', 'slug', 'color', 'description', 'tags',
)), )),
) )
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'name', 'slug', 'description', 'tags', 'name', 'slug', 'color', 'description', 'tags',
] ]

View File

@ -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),
),
]

View File

@ -7,6 +7,7 @@ from circuits.choices import *
from dcim.models import CabledObjectModel from dcim.models import CabledObjectModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
from utilities.fields import ColorField
__all__ = ( __all__ = (
'Circuit', '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 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". "Long Haul," "Metro," or "Out-of-Band".
""" """
color = ColorField(
verbose_name=_('color'),
blank=True
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('circuits:circuittype', args=[self.pk]) return reverse('circuits:circuittype', args=[self.pk])

View File

@ -28,6 +28,7 @@ class CircuitTypeTable(NetBoxTable):
linkify=True, linkify=True,
verbose_name=_('Name'), verbose_name=_('Name'),
) )
color = columns.ColorColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='circuits:circuittype_list' url_name='circuits:circuittype_list'
) )
@ -40,7 +41,7 @@ class CircuitTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CircuitType model = CircuitType
fields = ( 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') default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')

View File

@ -4,6 +4,7 @@ from core.choices import *
from core.models import * from core.models import *
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
from netbox.utils import get_data_backend_choices
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from .nested_serializers import * from .nested_serializers import *
@ -19,7 +20,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
view_name='core-api:datasource-detail' view_name='core-api:datasource-detail'
) )
type = ChoiceField( type = ChoiceField(
choices=DataSourceTypeChoices choices=get_data_backend_choices()
) )
status = ChoiceField( status = ChoiceField(
choices=DataSourceStatusChoices, choices=DataSourceStatusChoices,
@ -68,5 +69,5 @@ class JobSerializer(BaseModelSerializer):
model = Job model = Job
fields = [ fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval', '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',
] ]

View File

@ -7,18 +7,6 @@ from utilities.choices import ChoiceSet
# Data sources # 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): class DataSourceStatusChoices(ChoiceSet):
NEW = 'new' NEW = 'new'
QUEUED = 'queued' QUEUED = 'queued'

View File

@ -10,61 +10,24 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netbox.registry import registry from netbox.data_backends import DataBackend
from .choices import DataSourceTypeChoices from netbox.utils import register_data_backend
from .exceptions import SyncError from .exceptions import SyncError
__all__ = ( __all__ = (
'LocalBackend',
'GitBackend', 'GitBackend',
'LocalBackend',
'S3Backend', 'S3Backend',
) )
logger = logging.getLogger('netbox.data_backends') logger = logging.getLogger('netbox.data_backends')
def register_backend(name): @register_data_backend()
"""
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)
class LocalBackend(DataBackend): class LocalBackend(DataBackend):
name = 'local'
label = _('Local')
is_local = True
@contextmanager @contextmanager
def fetch(self): def fetch(self):
@ -74,8 +37,10 @@ class LocalBackend(DataBackend):
yield local_path yield local_path
@register_backend(DataSourceTypeChoices.GIT) @register_data_backend()
class GitBackend(DataBackend): class GitBackend(DataBackend):
name = 'git'
label = 'Git'
parameters = { parameters = {
'username': forms.CharField( 'username': forms.CharField(
required=False, required=False,
@ -144,8 +109,10 @@ class GitBackend(DataBackend):
local_path.cleanup() local_path.cleanup()
@register_backend(DataSourceTypeChoices.AMAZON_S3) @register_data_backend()
class S3Backend(DataBackend): class S3Backend(DataBackend):
name = 'amazon-s3'
label = 'Amazon S3'
parameters = { parameters = {
'aws_access_key_id': forms.CharField( 'aws_access_key_id': forms.CharField(
label=_('AWS access key ID'), label=_('AWS access key ID'),

View File

@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
import django_filters import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices
from .choices import * from .choices import *
from .models import * from .models import *
@ -16,7 +17,7 @@ __all__ = (
class DataSourceFilterSet(NetBoxModelFilterSet): class DataSourceFilterSet(NetBoxModelFilterSet):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=DataSourceTypeChoices, choices=get_data_backend_choices,
null_value=None null_value=None
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(

View File

@ -1,10 +1,9 @@
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.choices import DataSourceTypeChoices
from core.models import * from core.models import *
from netbox.forms import NetBoxModelBulkEditForm 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.fields import CommentField
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
@ -16,9 +15,8 @@ __all__ = (
class DataSourceBulkEditForm(NetBoxModelBulkEditForm): class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'), label=_('Type'),
choices=add_blank_choice(DataSourceTypeChoices), choices=get_data_backend_choices,
required=False, required=False
initial=''
) )
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
required=False, required=False,

View File

@ -8,6 +8,7 @@ from core.models import *
from extras.forms.mixins import SavedFiltersMixin from extras.forms.mixins import SavedFiltersMixin
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelFilterSetForm 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 import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import APISelectMultiple, DateTimePicker from utilities.forms.widgets import APISelectMultiple, DateTimePicker
@ -27,7 +28,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
choices=DataSourceTypeChoices, choices=get_data_backend_choices,
required=False required=False
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(

View File

@ -7,6 +7,7 @@ from core.forms.mixins import SyncedDataMixin
from core.models import * from core.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from netbox.registry import registry from netbox.registry import registry
from netbox.utils import get_data_backend_choices
from utilities.forms import get_field_value from utilities.forms import get_field_value
from utilities.forms.fields import CommentField from utilities.forms.fields import CommentField
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
@ -18,6 +19,10 @@ __all__ = (
class DataSourceForm(NetBoxModelForm): class DataSourceForm(NetBoxModelForm):
type = forms.ChoiceField(
choices=get_data_backend_choices,
widget=HTMXSelect()
)
comments = CommentField() comments = CommentField()
class Meta: class Meta:
@ -26,7 +31,6 @@ class DataSourceForm(NetBoxModelForm):
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags', 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
] ]
widgets = { widgets = {
'type': HTMXSelect(),
'ignore_rules': forms.Textarea( 'ignore_rules': forms.Textarea(
attrs={ attrs={
'rows': 5, 'rows': 5,
@ -56,6 +60,7 @@ class DataSourceForm(NetBoxModelForm):
# Add backend-specific form fields # Add backend-specific form fields
self.backend_fields = [] self.backend_fields = []
if backend:
for name, form_field in backend.parameters.items(): for name, form_field in backend.parameters.items():
field_name = f'backend_{name}' field_name = f'backend_{name}'
self.backend_fields.append(field_name) self.backend_fields.append(field_name)

View File

@ -25,7 +25,7 @@ def sync_datasource(job, *args, **kwargs):
job.terminate() job.terminate()
except Exception as e: 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) DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) in (SyncError, JobTimeoutException): if type(e) in (SyncError, JobTimeoutException):
logging.error(e) logging.error(e)

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -45,9 +45,7 @@ class DataSource(JobsMixin, PrimaryModel):
) )
type = models.CharField( type = models.CharField(
verbose_name=_('type'), verbose_name=_('type'),
max_length=50, max_length=50
choices=DataSourceTypeChoices,
default=DataSourceTypeChoices.LOCAL
) )
source_url = models.CharField( source_url = models.CharField(
max_length=200, max_length=200,
@ -96,8 +94,9 @@ class DataSource(JobsMixin, PrimaryModel):
def docs_url(self): def docs_url(self):
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/' return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
def get_type_color(self): def get_type_display(self):
return DataSourceTypeChoices.colors.get(self.type) if backend := registry['data_backends'].get(self.type):
return backend.label
def get_status_color(self): def get_status_color(self):
return DataSourceStatusChoices.colors.get(self.status) return DataSourceStatusChoices.colors.get(self.status)
@ -110,10 +109,6 @@ class DataSource(JobsMixin, PrimaryModel):
def backend_class(self): def backend_class(self):
return registry['data_backends'].get(self.type) return registry['data_backends'].get(self.type)
@property
def is_local(self):
return self.type == DataSourceTypeChoices.LOCAL
@property @property
def ready_for_sync(self): def ready_for_sync(self):
return self.enabled and self.status not in ( return self.enabled and self.status not in (
@ -123,8 +118,14 @@ class DataSource(JobsMixin, PrimaryModel):
def clean(self): 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 # 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({ raise ValidationError({
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)" 'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
}) })

View File

@ -92,6 +92,11 @@ class Job(models.Model):
null=True, null=True,
blank=True blank=True
) )
error = models.TextField(
verbose_name=_('error'),
editable=False,
blank=True
)
job_id = models.UUIDField( job_id = models.UUIDField(
verbose_name=_('job ID'), verbose_name=_('job ID'),
unique=True unique=True
@ -158,7 +163,7 @@ class Job(models.Model):
# Handle webhooks # Handle webhooks
self.trigger_webhooks(event=EVENT_JOB_START) 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. Mark the job as completed, optionally specifying a particular termination status.
""" """
@ -168,6 +173,8 @@ class Job(models.Model):
# Mark the job as completed # Mark the job as completed
self.status = status self.status = status
if error:
self.error = error
self.completed = timezone.now() self.completed = timezone.now()
self.save() self.save()

View File

@ -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

View File

@ -3,6 +3,7 @@ import django_tables2 as tables
from core.models import * from core.models import *
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from .columns import BackendTypeColumn
__all__ = ( __all__ = (
'DataFileTable', 'DataFileTable',
@ -15,8 +16,8 @@ class DataSourceTable(NetBoxTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
type = columns.ChoiceFieldColumn( type = BackendTypeColumn(
verbose_name=_('Type'), verbose_name=_('Type')
) )
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(
verbose_name=_('Status'), verbose_name=_('Status'),
@ -34,8 +35,8 @@ class DataSourceTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = DataSource model = DataSource
fields = ( fields = (
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created', 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
'last_updated', 'file_count', 'created', 'last_updated', 'file_count',
) )
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count') default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')

View File

@ -47,7 +47,7 @@ class JobTable(NetBoxTable):
model = Job model = Job
fields = ( fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'job_id', 'completed', 'user', 'error', 'job_id',
) )
default_columns = ( default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',

View File

@ -2,7 +2,6 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
from ..choices import *
from ..models import * from ..models import *
@ -26,26 +25,26 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
data_sources = ( data_sources = (
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), DataSource(name='Data Source 1', type='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 2', type='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 3', type='local', source_url='file:///var/tmp/source3/'),
) )
DataSource.objects.bulk_create(data_sources) DataSource.objects.bulk_create(data_sources)
cls.create_data = [ cls.create_data = [
{ {
'name': 'Data Source 4', 'name': 'Data Source 4',
'type': DataSourceTypeChoices.GIT, 'type': 'git',
'source_url': 'https://example.com/git/source4' 'source_url': 'https://example.com/git/source4'
}, },
{ {
'name': 'Data Source 5', 'name': 'Data Source 5',
'type': DataSourceTypeChoices.GIT, 'type': 'git',
'source_url': 'https://example.com/git/source5' 'source_url': 'https://example.com/git/source5'
}, },
{ {
'name': 'Data Source 6', 'name': 'Data Source 6',
'type': DataSourceTypeChoices.GIT, 'type': 'git',
'source_url': 'https://example.com/git/source6' 'source_url': 'https://example.com/git/source6'
}, },
] ]
@ -63,7 +62,7 @@ class DataFileTest(
def setUpTestData(cls): def setUpTestData(cls):
datasource = DataSource.objects.create( datasource = DataSource.objects.create(
name='Data Source 1', name='Data Source 1',
type=DataSourceTypeChoices.LOCAL, type='local',
source_url='file:///var/tmp/source1/' source_url='file:///var/tmp/source1/'
) )

View File

@ -18,21 +18,21 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
data_sources = ( data_sources = (
DataSource( DataSource(
name='Data Source 1', name='Data Source 1',
type=DataSourceTypeChoices.LOCAL, type='local',
source_url='file:///var/tmp/source1/', source_url='file:///var/tmp/source1/',
status=DataSourceStatusChoices.NEW, status=DataSourceStatusChoices.NEW,
enabled=True enabled=True
), ),
DataSource( DataSource(
name='Data Source 2', name='Data Source 2',
type=DataSourceTypeChoices.LOCAL, type='local',
source_url='file:///var/tmp/source2/', source_url='file:///var/tmp/source2/',
status=DataSourceStatusChoices.SYNCING, status=DataSourceStatusChoices.SYNCING,
enabled=True enabled=True
), ),
DataSource( DataSource(
name='Data Source 3', name='Data Source 3',
type=DataSourceTypeChoices.GIT, type='git',
source_url='https://example.com/git/source3', source_url='https://example.com/git/source3',
status=DataSourceStatusChoices.COMPLETED, status=DataSourceStatusChoices.COMPLETED,
enabled=False enabled=False
@ -45,7 +45,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self): def test_type(self):
params = {'type': [DataSourceTypeChoices.LOCAL]} params = {'type': ['local']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self): def test_enabled(self):
@ -66,9 +66,9 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
data_sources = ( data_sources = (
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), DataSource(name='Data Source 1', type='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 2', type='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 3', type='local', source_url='file:///var/tmp/source3/'),
) )
DataSource.objects.bulk_create(data_sources) DataSource.objects.bulk_create(data_sources)

View File

@ -1,7 +1,6 @@
from django.utils import timezone from django.utils import timezone
from utilities.testing import ViewTestCases, create_tags from utilities.testing import ViewTestCases, create_tags
from ..choices import *
from ..models import * from ..models import *
@ -11,9 +10,9 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
data_sources = ( data_sources = (
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'), DataSource(name='Data Source 1', type='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 2', type='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 3', type='local', source_url='file:///var/tmp/source3/'),
) )
DataSource.objects.bulk_create(data_sources) DataSource.objects.bulk_create(data_sources)
@ -21,7 +20,7 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'name': 'Data Source X', 'name': 'Data Source X',
'type': DataSourceTypeChoices.GIT, 'type': 'git',
'source_url': 'http:///exmaple/com/foo/bar/', 'source_url': 'http:///exmaple/com/foo/bar/',
'description': 'Something', 'description': 'Something',
'comments': 'Foo bar baz', 'comments': 'Foo bar baz',
@ -29,10 +28,10 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
f"name,type,source_url,enabled", "name,type,source_url,enabled",
f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true", "Data Source 4,local,file:///var/tmp/source4/,true",
f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true", "Data Source 5,local,file:///var/tmp/source4/,true",
f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false", "Data Source 6,git,http:///exmaple/com/foo/bar/,false",
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -60,7 +59,7 @@ class DataFileTestCase(
def setUpTestData(cls): def setUpTestData(cls):
datasource = DataSource.objects.create( datasource = DataSource.objects.create(
name='Data Source 1', name='Data Source 1',
type=DataSourceTypeChoices.LOCAL, type='local',
source_url='file:///var/tmp/source1/' source_url='file:///var/tmp/source1/'
) )

View File

@ -100,7 +100,9 @@ class DataFileListView(generic.ObjectListView):
filterset = filtersets.DataFileFilterSet filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable table = tables.DataFileTable
actions = ('bulk_delete',) actions = {
'bulk_delete': {'delete'},
}
@register_model_view(DataFile) @register_model_view(DataFile)
@ -128,7 +130,10 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm filterset_form = forms.JobFilterForm
table = tables.JobTable table = tables.JobTable
actions = ('export', 'delete', 'bulk_delete') actions = {
'export': {'view'},
'bulk_delete': {'delete'},
}
class JobView(generic.ObjectView): class JobView(generic.ObjectView):

View File

@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', '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', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count', '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', 'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count', 'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count', 'inventory_item_template_count',

View File

@ -80,10 +80,10 @@ class RackWidthChoices(ChoiceSet):
WIDTH_23IN = 23 WIDTH_23IN = 23
CHOICES = ( CHOICES = (
(WIDTH_10IN, _('10 inches')), (WIDTH_10IN, _('{n} inches').format(n=10)),
(WIDTH_19IN, _('19 inches')), (WIDTH_19IN, _('{n} inches').format(n=19)),
(WIDTH_21IN, _('21 inches')), (WIDTH_21IN, _('{n} inches').format(n=21)),
(WIDTH_23IN, _('23 inches')), (WIDTH_23IN, _('{n} inches').format(n=23)),
) )

View File

@ -496,7 +496,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ 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): def search(self, queryset, name, value):

View File

@ -420,6 +420,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
widget=BulkEditNullBooleanSelect(), widget=BulkEditNullBooleanSelect(),
label=_('Is full depth') label=_('Is full depth')
) )
exclude_from_utilization = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
label=_('Exclude from utilization')
)
airflow = forms.ChoiceField( airflow = forms.ChoiceField(
label=_('Airflow'), label=_('Airflow'),
choices=add_blank_choice(DeviceAirflowChoices), choices=add_blank_choice(DeviceAirflowChoices),
@ -445,7 +450,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
model = DeviceType model = DeviceType
fieldsets = ( 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')), (_('Weight'), ('weight', 'weight_unit')),
) )
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')

View File

@ -335,8 +335,8 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags', 'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
] ]

View File

@ -116,17 +116,17 @@ class ModuleCommonForm(forms.Form):
# It is not possible to adopt components already belonging to a module # It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module: if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError( raise forms.ValidationError(
_("Cannot adopt {name} '{resolved_name}' as it already belongs to a module").format( _("Cannot adopt {model} {name} as it already belongs to a module").format(
name=template.component_model.__name__, model=template.component_model.__name__,
resolved_name=resolved_name name=resolved_name
) )
) )
# If we are not adopting components we error if the component exists # If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components: if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError( raise forms.ValidationError(
_("{name} - {resolved_name} already exists").format( _("A {model} named {name} already exists").format(
name=template.component_model.__name__, model=template.component_model.__name__,
resolved_name=resolved_name name=resolved_name
) )
) )

View File

@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')), (_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
(_('Chassis'), ( (_('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')), (_('Images'), ('front_image', 'rear_image')),
) )
@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
'comments', 'tags', 'description', 'comments', 'tags',
] ]
widgets = { widgets = {
'front_image': ClearableFileInput(attrs={ 'front_image': ClearableFileInput(attrs={

View File

@ -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),
),
]

View File

@ -534,14 +534,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
# Validate rear port assignment # Validate rear port assignment
if self.rear_port.device_type != self.device_type: if self.rear_port.device_type != self.device_type:
raise ValidationError( 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 # Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions: if self.rear_port_position > self.rear_port.positions:
raise ValidationError( raise ValidationError(
_("Invalid rear port position ({}); rear port {} has only {} positions").format( _("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions position=self.rear_port_position,
name=self.rear_port.name,
count=self.rear_port.positions
) )
) )

View File

@ -106,6 +106,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
default=1.0, default=1.0,
verbose_name=_('height (U)') 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( is_full_depth = models.BooleanField(
default=True, default=True,
verbose_name=_('is full depth'), verbose_name=_('is full depth'),
@ -297,8 +302,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
) )
if d.position not in u_available: if d.position not in u_available:
raise ValidationError({ raise ValidationError({
'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of " 'u_height': _(
"{}U").format(d, d.rack, self.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. # 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:
if self.primary_ip4.family != 4: if self.primary_ip4.family != 4:
raise ValidationError({ 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: if self.primary_ip4.assigned_object in vc_interfaces:
pass pass
@ -924,13 +931,13 @@ class Device(
else: else:
raise ValidationError({ raise ValidationError({
'primary_ip4': _( 'primary_ip4': _(
"The specified IP address ({primary_ip4}) is not assigned to this device." "The specified IP address ({ip}) is not assigned to this device."
).format(primary_ip4=self.primary_ip4) ).format(ip=self.primary_ip4)
}) })
if self.primary_ip6: if self.primary_ip6:
if self.primary_ip6.family != 6: if self.primary_ip6.family != 6:
raise ValidationError({ 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: if self.primary_ip6.assigned_object in vc_interfaces:
pass pass
@ -939,8 +946,8 @@ class Device(
else: else:
raise ValidationError({ raise ValidationError({
'primary_ip6': _( 'primary_ip6': _(
"The specified IP address ({primary_ip6}) is not assigned to this device." "The specified IP address ({ip}) is not assigned to this device."
).format(primary_ip6=self.primary_ip6) ).format(ip=self.primary_ip6)
}) })
if self.oob_ip: if self.oob_ip:
if self.oob_ip.assigned_object in vc_interfaces: if self.oob_ip.assigned_object in vc_interfaces:
@ -958,17 +965,19 @@ class Device(
raise ValidationError({ raise ValidationError({
'platform': _( 'platform': _(
"The assigned platform is limited to {platform_manufacturer} device types, but this device's " "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( ).format(
platform_manufacturer=self.platform.manufacturer, 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) # 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: if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
raise ValidationError({ 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 # Validate virtual chassis assignment
@ -1440,8 +1449,8 @@ class VirtualDeviceContext(PrimaryModel):
if primary_ip.family != family: if primary_ip.family != family:
raise ValidationError({ raise ValidationError({
f'primary_ip{family}': _( f'primary_ip{family}': _(
"{primary_ip} is not an IPv{family} address." "{ip} is not an IPv{family} address."
).format(family=family, primary_ip=primary_ip) ).format(family=family, ip=primary_ip)
}) })
device_interfaces = self.device.vc_interfaces(if_master=False) device_interfaces = self.device.vc_interfaces(if_master=False)
if primary_ip.assigned_object not in device_interfaces: if primary_ip.assigned_object not in device_interfaces:

View File

@ -357,7 +357,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
return [u for u in elevation.values()] 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). 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 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 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 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 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 # Gather all devices which consume U space within the rack
devices = self.devices.prefetch_related('device_type').filter(position__gte=1) 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: if exclude is not None:
devices = devices.exclude(pk__in=exclude) devices = devices.exclude(pk__in=exclude)
@ -453,7 +457,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
""" """
# Determine unoccupied units # Determine unoccupied units
total_units = len(list(self.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 # Remove reserved units
for ru in self.get_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] invalid_units = [u for u in self.units if u not in self.rack.units]
if invalid_units: if invalid_units:
raise ValidationError({ raise ValidationError({
'units': _("Invalid unit(s) for {}U rack: {}").format( 'units': _("Invalid unit(s) for {height}U rack: {unit_list}").format(
self.rack.u_height, height=self.rack.u_height,
', '.join([str(u) for u in invalid_units]), 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] conflicting_units = [u for u in self.units if u in reserved_units]
if conflicting_units: if conflicting_units:
raise ValidationError({ raise ValidationError({
'units': _('The following units have already been reserved: {}').format( 'units': _('The following units have already been reserved: {unit_list}').format(
', '.join([str(u) for u in conflicting_units]), unit_list=', '.join([str(u) for u in conflicting_units])
) )
}) })

View File

@ -159,6 +159,7 @@ class CableTraceSVG:
labels.append(location_label) labels.append(location_label)
elif instance._meta.model_name == 'circuit': elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}' labels[0] = f'Circuit {instance}'
labels.append(instance.type)
labels.append(instance.provider) labels.append(instance.provider)
if instance.description: if instance.description:
labels.append(instance.description) labels.append(instance.description)
@ -181,6 +182,8 @@ class CableTraceSVG:
if hasattr(instance, 'role'): if hasattr(instance, 'role'):
# Device # Device
return instance.role.color return instance.role.color
elif instance._meta.model_name == 'circuit' and instance.type.color:
return instance.type.color
else: else:
# Other parent object # Other parent object
return 'e0e0e0' return 'e0e0e0'

View File

@ -98,6 +98,7 @@ class DeviceTypeTable(NetBoxTable):
verbose_name=_('U Height'), verbose_name=_('U Height'),
template_code='{{ value|floatformat }}' template_code='{{ value|floatformat }}'
) )
exclude_from_utilization = columns.BooleanColumn()
weight = columns.TemplateColumn( weight = columns.TemplateColumn(
verbose_name=_('Weight'), verbose_name=_('Weight'),
template_code=WEIGHT, template_code=WEIGHT,
@ -142,9 +143,9 @@ class DeviceTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.DeviceType model = models.DeviceType
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'last_updated', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

View File

@ -238,6 +238,40 @@ class RackTestCase(TestCase):
# Check that Device1 is now assigned to Site B # Check that Device1 is now assigned to Site B
self.assertEqual(Device.objects.get(pk=device1.pk).site, 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): class DeviceTestCase(TestCase):

View File

@ -20,6 +20,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable from ipam.tables import InterfaceVLANTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
@ -46,15 +47,11 @@ CABLE_TERMINATION_TYPES = {
class DeviceComponentsView(generic.ObjectChildrenView): class DeviceComponentsView(generic.ObjectChildrenView):
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
'bulk_disconnect': {'change'}, 'bulk_disconnect': {'change'},
}) }
queryset = Device.objects.all() queryset = Device.objects.all()
def get_children(self, request, parent): def get_children(self, request, parent):
@ -1977,7 +1974,10 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
template_name = 'dcim/device/modulebays.html' template_name = 'dcim/device/modulebays.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.module_bay_count, badge=lambda obj: obj.module_bay_count,
@ -1993,7 +1993,10 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
template_name = 'dcim/device/devicebays.html' template_name = 'dcim/device/devicebays.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.device_bay_count, badge=lambda obj: obj.device_bay_count,
@ -2005,11 +2008,14 @@ class DeviceDeviceBaysView(DeviceComponentsView):
@register_model_view(Device, 'inventory') @register_model_view(Device, 'inventory')
class DeviceInventoryView(DeviceComponentsView): class DeviceInventoryView(DeviceComponentsView):
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
child_model = InventoryItem child_model = InventoryItem
table = tables.DeviceInventoryItemTable table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
template_name = 'dcim/device/inventory.html' template_name = 'dcim/device/inventory.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Inventory Items'), label=_('Inventory Items'),
badge=lambda obj: obj.inventory_item_count, badge=lambda obj: obj.inventory_item_count,
@ -2187,14 +2193,10 @@ class ConsolePortListView(generic.ObjectListView):
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable table = tables.ConsolePortTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(ConsolePort) @register_model_view(ConsolePort)
@ -2259,14 +2261,10 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable table = tables.ConsoleServerPortTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(ConsoleServerPort) @register_model_view(ConsoleServerPort)
@ -2331,14 +2329,10 @@ class PowerPortListView(generic.ObjectListView):
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable table = tables.PowerPortTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(PowerPort) @register_model_view(PowerPort)
@ -2403,14 +2397,10 @@ class PowerOutletListView(generic.ObjectListView):
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable table = tables.PowerOutletTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(PowerOutlet) @register_model_view(PowerOutlet)
@ -2475,14 +2465,10 @@ class InterfaceListView(generic.ObjectListView):
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable table = tables.InterfaceTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(Interface) @register_model_view(Interface)
@ -2595,14 +2581,10 @@ class FrontPortListView(generic.ObjectListView):
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable table = tables.FrontPortTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(FrontPort) @register_model_view(FrontPort)
@ -2667,14 +2649,10 @@ class RearPortListView(generic.ObjectListView):
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable table = tables.RearPortTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(RearPort) @register_model_view(RearPort)
@ -2739,14 +2717,10 @@ class ModuleBayListView(generic.ObjectListView):
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable table = tables.ModuleBayTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(ModuleBay) @register_model_view(ModuleBay)
@ -2803,14 +2777,10 @@ class DeviceBayListView(generic.ObjectListView):
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable table = tables.DeviceBayTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(DeviceBay) @register_model_view(DeviceBay)
@ -2936,14 +2906,10 @@ class InventoryItemListView(generic.ObjectListView):
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
template_name = 'dcim/component_list.html' template_name = 'dcim/component_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') actions = {
action_perms = defaultdict(set, **{ **DEFAULT_ACTION_PERMISSIONS,
'add': {'add'},
'import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_rename': {'change'}, 'bulk_rename': {'change'},
}) }
@register_model_view(InventoryItem) @register_model_view(InventoryItem)
@ -3175,7 +3141,12 @@ class CableListView(generic.ObjectListView):
filterset = filtersets.CableFilterSet filterset = filtersets.CableFilterSet
filterset_form = forms.CableFilterForm filterset_form = forms.CableFilterForm
table = tables.CableTable table = tables.CableTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete') actions = {
'import': {'add'},
'export': {'view'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(Cable) @register_model_view(Cable)
@ -3269,7 +3240,9 @@ class ConsoleConnectionsListView(generic.ObjectListView):
filterset_form = forms.ConsoleConnectionFilterForm filterset_form = forms.ConsoleConnectionFilterForm
table = tables.ConsoleConnectionTable table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = ('export',) actions = {
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@ -3283,7 +3256,9 @@ class PowerConnectionsListView(generic.ObjectListView):
filterset_form = forms.PowerConnectionFilterForm filterset_form = forms.PowerConnectionFilterForm
table = tables.PowerConnectionTable table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = ('export',) actions = {
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@ -3297,7 +3272,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
filterset_form = forms.InterfaceConnectionFilterForm filterset_form = forms.InterfaceConnectionFilterForm
table = tables.InterfaceConnectionTable table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = ('export',) actions = {
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {

View File

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

View File

@ -59,7 +59,7 @@ class Command(BaseCommand):
logger.error(f"Exception raised during script execution: {e}") logger.error(f"Exception raised during script execution: {e}")
clear_webhooks.send(request) clear_webhooks.send(request)
job.data = ScriptOutputSerializer(script).data 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}") logger.info(f"Script completed in {job.duration}")

View File

@ -287,8 +287,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
except ValidationError as err: except ValidationError as err:
raise ValidationError({ raise ValidationError({
'default': _( 'default': _(
'Invalid default value "{default}": {message}' 'Invalid default value "{value}": {error}'
).format(default=self.default, message=err.message) ).format(value=self.default, error=err.message)
}) })
# Minimum/maximum values can be set only for numeric fields # Minimum/maximum values can be set only for numeric fields
@ -332,8 +332,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
elif self.object_type: elif self.object_type:
raise ValidationError({ raise ValidationError({
'object_type': _( 'object_type': _(
"{type_display} fields may not define an object type.") "{type} fields may not define an object type.")
.format(type_display=self.get_type_display()) .format(type=self.get_type_display())
}) })
def serialize(self, value): def serialize(self, value):

View File

@ -1,148 +1,9 @@
import collections
from importlib import import_module
from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
from packaging import version
from netbox.registry import registry
from netbox.search import register_search
from .navigation import * from .navigation import *
from .registration import * from .registration import *
from .templates import * from .templates import *
from .utils import * from .utils import *
from netbox.plugins import PluginConfig
# Initialize plugin registry
registry['plugins'].update({
'graphql_schemas': [],
'menus': [],
'menu_items': {},
'preferences': {},
'template_extensions': collections.defaultdict(list),
})
DEFAULT_RESOURCE_PATHS = {
'search_indexes': 'search.indexes',
'graphql_schema': 'graphql.schema',
'menu': 'navigation.menu',
'menu_items': 'navigation.menu_items',
'template_extensions': 'template_content.template_extensions',
'user_preferences': 'preferences.preferences',
}
# # TODO: Remove in v4.0
# Plugin AppConfig class warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
#
class PluginConfig(AppConfig):
"""
Subclass of Django's built-in AppConfig class, to be used for NetBox plugins.
"""
# Plugin metadata
author = ''
author_email = ''
description = ''
version = ''
# Root URL path under /plugins. If not set, the plugin's label will be used.
base_url = None
# Minimum/maximum compatible versions of NetBox
min_version = None
max_version = None
# Default configuration parameters
default_settings = {}
# Mandatory configuration parameters
required_settings = []
# Middleware classes provided by the plugin
middleware = []
# Django-rq queues dedicated to the plugin
queues = []
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
# Optional plugin resources
search_indexes = None
graphql_schema = None
menu = None
menu_items = None
template_extensions = None
user_preferences = None
def _load_resource(self, name):
# Import from the configured path, if defined.
if path := getattr(self, name, None):
return import_string(f"{self.__module__}.{path}")
# Fall back to the resource's default path. Return None if the module has not been provided.
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
default_module, resource_name = default_path.rsplit('.', 1)
try:
module = import_module(default_module)
return getattr(module, resource_name, None)
except ModuleNotFoundError:
pass
def ready(self):
plugin_name = self.name.rsplit('.', 1)[-1]
# Register search extensions (if defined)
search_indexes = self._load_resource('search_indexes') or []
for idx in search_indexes:
register_search(idx)
# Register template content (if defined)
if template_extensions := self._load_resource('template_extensions'):
register_template_extensions(template_extensions)
# Register navigation menu and/or menu items (if defined)
if menu := self._load_resource('menu'):
register_menu(menu)
if menu_items := self._load_resource('menu_items'):
register_menu_items(self.verbose_name, menu_items)
# Register GraphQL schema (if defined)
if graphql_schema := self._load_resource('graphql_schema'):
register_graphql_schema(graphql_schema)
# Register user preferences (if defined)
if user_preferences := self._load_resource('user_preferences'):
register_user_preferences(plugin_name, user_preferences)
@classmethod
def validate(cls, user_config, netbox_version):
# Enforce version constraints
current_version = version.parse(netbox_version)
if cls.min_version is not None:
min_version = version.parse(cls.min_version)
if current_version < min_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
)
# Verify required configuration settings
for setting in cls.required_settings:
if setting not in user_config:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of "
f"configuration.py."
)
# Apply default configuration values
for setting, value in cls.default_settings.items():
if setting not in user_config:
user_config[setting] = value

View File

@ -1,72 +1,7 @@
from netbox.navigation import MenuGroup import warnings
from utilities.choices import ButtonColorChoices
from django.utils.text import slugify
__all__ = ( from netbox.plugins.navigation import *
'PluginMenu',
'PluginMenuButton',
'PluginMenuItem',
)
class PluginMenu: # TODO: Remove in v4.0
icon_class = 'mdi mdi-puzzle' warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
def __init__(self, label, groups, icon_class=None):
self.label = label
self.groups = [
MenuGroup(label, items) for label, items in groups
]
if icon_class is not None:
self.icon_class = icon_class
@property
def name(self):
return slugify(self.label)
class PluginMenuItem:
"""
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
specifying additional link buttons that appear to the right of the item in the van menu.
Links are specified as Django reverse URL strings.
Buttons are each specified as a list of PluginMenuButton instances.
"""
permissions = []
buttons = []
def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None):
self.link = link
self.link_text = link_text
self.staff_only = staff_only
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.")
self.permissions = permissions
if buttons is not None:
if type(buttons) not in (list, tuple):
raise TypeError("Buttons must be passed as a tuple or list.")
self.buttons = buttons
class PluginMenuButton:
"""
This class represents a button within a PluginMenuItem. Note that button colors should come from
ButtonColorChoices.
"""
color = ButtonColorChoices.DEFAULT
permissions = []
def __init__(self, link, title, icon_class, color=None, permissions=None):
self.link = link
self.title = title
self.icon_class = icon_class
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.")
self.permissions = permissions
if color is not None:
if color not in ButtonColorChoices.values():
raise ValueError("Button color must be a choice within ButtonColorChoices.")
self.color = color

View File

@ -1,64 +1,7 @@
import inspect import warnings
from netbox.registry import registry from netbox.plugins.registration import *
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension
__all__ = (
'register_graphql_schema',
'register_menu',
'register_menu_items',
'register_template_extensions',
'register_user_preferences',
)
def register_template_extensions(class_list): # TODO: Remove in v4.0
""" warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
Register a list of PluginTemplateExtension classes
"""
# Validation
for template_extension in class_list:
if not inspect.isclass(template_extension):
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
if not issubclass(template_extension, PluginTemplateExtension):
raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
if template_extension.model is None:
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
def register_menu(menu):
if not isinstance(menu, PluginMenu):
raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
registry['plugins']['menus'].append(menu)
def register_menu_items(section_name, class_list):
"""
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
"""
# Validation
for menu_link in class_list:
if not isinstance(menu_link, PluginMenuItem):
raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem")
for button in menu_link.buttons:
if not isinstance(button, PluginMenuButton):
raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
registry['plugins']['menu_items'][section_name] = class_list
def register_graphql_schema(graphql_schema):
"""
Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
"""
registry['plugins']['graphql_schemas'].append(graphql_schema)
def register_user_preferences(plugin_name, preferences):
"""
Register a list of user preferences defined by a plugin.
"""
registry['plugins']['preferences'][plugin_name] = preferences

View File

@ -1,73 +1,7 @@
from django.template.loader import get_template import warnings
__all__ = ( from netbox.plugins.templates import *
'PluginTemplateExtension',
)
class PluginTemplateExtension: # TODO: Remove in v4.0
""" warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
This class is used to register plugin content to be injected into core NetBox templates. It contains methods
that are overridden by plugin authors to return template content.
The `model` attribute on the class defines the which model detail page this class renders content for. It
should be set as a string in the form '<app_label>.<model_name>'. render() provides the following context data:
* object - The object being viewed
* request - The current request
* settings - Global NetBox settings
* config - Plugin-specific configuration parameters
"""
model = None
def __init__(self, context):
self.context = context
def render(self, template_name, extra_context=None):
"""
Convenience method for rendering the specified Django template using the default context data. An additional
context dictionary may be passed as `extra_context`.
"""
if extra_context is None:
extra_context = {}
elif not isinstance(extra_context, dict):
raise TypeError("extra_context must be a dictionary")
return get_template(template_name).render({**self.context, **extra_context})
def left_page(self):
"""
Content that will be rendered on the left of the detail page view. Content should be returned as an
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
"""
raise NotImplementedError
def right_page(self):
"""
Content that will be rendered on the right of the detail page view. Content should be returned as an
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
"""
raise NotImplementedError
def full_width_page(self):
"""
Content that will be rendered within the full width of the detail page view. Content should be returned as an
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
"""
raise NotImplementedError
def buttons(self):
"""
Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
automatically handled.
"""
raise NotImplementedError
def list_buttons(self):
"""
Buttons that will be rendered and added to the existing list of buttons on the list view. Content
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
automatically handled.
"""
raise NotImplementedError

View File

@ -1,41 +1,7 @@
from importlib import import_module import warnings
from django.apps import apps from netbox.plugins.urls import *
from django.conf import settings
from django.conf.urls import include
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
from django.utils.module_loading import import_string, module_has_submodule
from . import views
# Initialize URL base, API, and admin URL patterns for plugins # TODO: Remove in v4.0
plugin_patterns = [] warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
plugin_api_patterns = [
path('', views.PluginsAPIRootView.as_view(), name='api-root'),
path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list')
]
plugin_admin_patterns = [
path('installed-plugins/', staff_member_required(views.InstalledPluginsAdminView.as_view()), name='plugins_list')
]
# Register base/API URL patterns for each plugin
for plugin_path in settings.PLUGINS:
plugin = import_module(plugin_path)
plugin_name = plugin_path.split('.')[-1]
app = apps.get_app_config(plugin_name)
base_url = getattr(app, 'base_url') or app.label
# Check if the plugin specifies any base URLs
if module_has_submodule(plugin, 'urls'):
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
plugin_patterns.append(
path(f"{base_url}/", include((urlpatterns, app.label)))
)
# Check if the plugin specifies any API URLs
if module_has_submodule(plugin, 'api.urls'):
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
plugin_api_patterns.append(
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
)

View File

@ -1,37 +1,7 @@
from django.apps import apps import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
__all__ = ( from netbox.plugins.utils import *
'get_installed_plugins',
'get_plugin_config',
)
def get_installed_plugins(): # TODO: Remove in v4.0
""" warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
Return a dictionary mapping the names of installed plugins to their versions.
"""
plugins = {}
for plugin_name in settings.PLUGINS:
plugin_name = plugin_name.rsplit('.', 1)[-1]
plugin_config = apps.get_app_config(plugin_name)
plugins[plugin_name] = getattr(plugin_config, 'version', None)
return dict(sorted(plugins.items()))
def get_plugin_config(plugin_name, parameter, default=None):
"""
Return the value of the specified plugin configuration parameter.
Args:
plugin_name: The name of the plugin
parameter: The name of the configuration parameter
default: The value to return if the parameter is not defined (default: None)
"""
try:
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
return plugin_config.get(parameter, default)
except KeyError:
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

View File

@ -1,89 +1,7 @@
from collections import OrderedDict import warnings
from django.apps import apps from netbox.plugins.views import *
from django.conf import settings
from django.shortcuts import render
from django.urls.exceptions import NoReverseMatch
from django.views.generic import View
from drf_spectacular.utils import extend_schema
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
class InstalledPluginsAdminView(View): # TODO: Remove in v4.0
""" warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
Admin view for listing all installed plugins
"""
def get(self, request):
plugins = [apps.get_app_config(plugin) for plugin in settings.PLUGINS]
return render(request, 'extras/admin/plugins_list.html', {
'plugins': plugins,
})
@extend_schema(exclude=True)
class InstalledPluginsAPIView(APIView):
"""
API view for listing all installed plugins
"""
permission_classes = [permissions.IsAdminUser]
_ignore_model_permissions = True
schema = None
def get_view_name(self):
return "Installed Plugins"
@staticmethod
def _get_plugin_data(plugin_app_config):
return {
'name': plugin_app_config.verbose_name,
'package': plugin_app_config.name,
'author': plugin_app_config.author,
'author_email': plugin_app_config.author_email,
'description': plugin_app_config.description,
'version': plugin_app_config.version
}
def get(self, request, format=None):
return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS])
@extend_schema(exclude=True)
class PluginsAPIRootView(APIView):
_ignore_model_permissions = True
schema = None
def get_view_name(self):
return "Plugins"
@staticmethod
def _get_plugin_entry(plugin, app_config, request, format):
# Check if the plugin specifies any API URLs
api_app_name = f'{app_config.name}-api'
try:
entry = (getattr(app_config, 'base_url', app_config.label), reverse(
f"plugins-api:{api_app_name}:api-root",
request=request,
format=format
))
except NoReverseMatch:
# The plugin does not include an api-root url
entry = None
return entry
def get(self, request, format=None):
entries = []
for plugin in settings.PLUGINS:
app_config = apps.get_app_config(plugin)
entry = self._get_plugin_entry(plugin, app_config, request, format)
if entry is not None:
entries.append(entry)
return Response(OrderedDict((
('installed-plugins', reverse('plugins-api:plugins-list', request=request, format=format)),
*entries
)))

View File

@ -40,8 +40,8 @@ def run_report(job, *args, **kwargs):
try: try:
report.run(job) report.run(job)
except Exception: except Exception as e:
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
logging.error(f"Error during execution of report {job.name}") logging.error(f"Error during execution of report {job.name}")
finally: finally:
# Schedule the next job if an interval has been set # Schedule the next job if an interval has been set
@ -230,7 +230,7 @@ class Report(object):
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>") self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
logger.error(f"Exception raised during report execution: {e}") 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 # Perform any post-run tasks
self.post_run() self.post_run()

View File

@ -519,7 +519,7 @@ def run_script(data, request, job, commit=True, **kwargs):
logger.error(f"Exception raised during script execution: {e}") logger.error(f"Exception raised during script execution: {e}")
script.log_info("Database changes have been reverted due to error.") script.log_info("Database changes have been reverted due to error.")
job.data = ScriptOutputSerializer(script).data job.data = ScriptOutputSerializer(script).data
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
clear_webhooks.send(request) clear_webhooks.send(request)
logger.info(f"Script completed in {job.duration}") logger.info(f"Script completed in {job.duration}")

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ from core.tables import JobTable
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from netbox.config import get_config, PARAMS from netbox.config import get_config, PARAMS
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
@ -210,7 +211,10 @@ class ExportTemplateListView(generic.ObjectListView):
filterset_form = forms.ExportTemplateFilterForm filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable table = tables.ExportTemplateTable
template_name = 'extras/exporttemplate_list.html' template_name = 'extras/exporttemplate_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync') actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ExportTemplate) @register_model_view(ExportTemplate)
@ -472,7 +476,12 @@ class ConfigContextListView(generic.ObjectListView):
filterset_form = forms.ConfigContextFilterForm filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable table = tables.ConfigContextTable
template_name = 'extras/configcontext_list.html' template_name = 'extras/configcontext_list.html'
actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync') actions = {
'add': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_sync': {'sync'},
}
@register_model_view(ConfigContext) @register_model_view(ConfigContext)
@ -576,7 +585,10 @@ class ConfigTemplateListView(generic.ObjectListView):
filterset_form = forms.ConfigTemplateFilterForm filterset_form = forms.ConfigTemplateFilterForm
table = tables.ConfigTemplateTable table = tables.ConfigTemplateTable
template_name = 'extras/configtemplate_list.html' template_name = 'extras/configtemplate_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync') actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ConfigTemplate) @register_model_view(ConfigTemplate)
@ -627,7 +639,9 @@ class ObjectChangeListView(generic.ObjectListView):
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable table = tables.ObjectChangeTable
template_name = 'extras/objectchange_list.html' template_name = 'extras/objectchange_list.html'
actions = ('export',) actions = {
'export': {'view'},
}
@register_model_view(ObjectChange) @register_model_view(ObjectChange)
@ -693,7 +707,9 @@ class ImageAttachmentListView(generic.ObjectListView):
filterset = filtersets.ImageAttachmentFilterSet filterset = filtersets.ImageAttachmentFilterSet
filterset_form = forms.ImageAttachmentFilterForm filterset_form = forms.ImageAttachmentFilterForm
table = tables.ImageAttachmentTable table = tables.ImageAttachmentTable
actions = ('export',) actions = {
'export': {'view'},
}
@register_model_view(ImageAttachment, 'edit') @register_model_view(ImageAttachment, 'edit')
@ -736,7 +752,12 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable table = tables.JournalEntryTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete') actions = {
'import': {'add'},
'export': {'view'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(JournalEntry) @register_model_view(JournalEntry)

View File

@ -372,14 +372,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
# Do not allow assigning a network ID or broadcast address to an interface. # Do not allow assigning a network ID or broadcast address to an interface.
if interface and (address := self.cleaned_data.get('address')): if interface and (address := self.cleaned_data.get('address')):
if address.ip == address.network: 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): if address.version == 4 and address.prefixlen not in (31, 32):
raise ValidationError(msg) raise ValidationError(msg)
if address.version == 6 and address.prefixlen not in (127, 128): if address.version == 6 and address.prefixlen not in (127, 128):
raise ValidationError(msg) raise ValidationError(msg)
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): 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( msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
address=address ip=address.ip
) )
raise ValidationError(msg) raise ValidationError(msg)

View File

@ -140,8 +140,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
if covering_aggregates: if covering_aggregates:
raise ValidationError({ raise ValidationError({
'prefix': _( 'prefix': _(
"Aggregates cannot overlap. {} is already covered by an existing aggregate ({})." "Aggregates cannot overlap. {prefix} is already covered by an existing aggregate ({aggregate})."
).format(self.prefix, covering_aggregates[0]) ).format(
prefix=self.prefix,
aggregate=covering_aggregates[0]
)
}) })
# Ensure that the aggregate being added does not cover an existing aggregate # 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) covered_aggregates = covered_aggregates.exclude(pk=self.pk)
if covered_aggregates: if covered_aggregates:
raise ValidationError({ raise ValidationError({
'prefix': _("Aggregates cannot overlap. {} covers an existing aggregate ({}).").format( 'prefix': _(
self.prefix, covered_aggregates[0] "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): if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_prefixes = self.get_duplicates() duplicate_prefixes = self.get_duplicates()
if duplicate_prefixes: if duplicate_prefixes:
table = _("VRF {vrf}").format(vrf=self.vrf) if self.vrf else _("global table")
raise ValidationError({ raise ValidationError({
'prefix': _("Duplicate prefix found in {}: {}").format( 'prefix': _("Duplicate prefix found in {table}: {prefix}").format(
_("VRF {}").format(self.vrf) if self.vrf else _("global table"), table=table,
duplicate_prefixes.first(), prefix=duplicate_prefixes.first(),
) )
}) })
@ -843,10 +850,11 @@ class IPAddress(PrimaryModel):
self.role not in IPADDRESS_ROLES_NONUNIQUE or self.role not in IPADDRESS_ROLES_NONUNIQUE or
any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips) 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({ raise ValidationError({
'address': _("Duplicate IP address found in {}: {}").format( 'address': _("Duplicate IP address found in {table}: {ipaddress}").format(
_("VRF {}").format(self.vrf) if self.vrf else _("global table"), table=table,
duplicate_ips.first(), ipaddress=duplicate_ips.first(),
) )
}) })

View File

@ -234,8 +234,8 @@ class VLAN(PrimaryModel):
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
raise ValidationError({ raise ValidationError({
'vid': _( 'vid': _(
"VID must be between {min_vid} and {max_vid} for VLANs in group {group}" "VID must be between {minimum} and {maximum} for VLANs in group {group}"
).format(min_vid=self.group.min_vid, max_vid=self.group.max_vid, group=self.group) ).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group)
}) })
def get_status_color(self): def get_status_color(self):

View File

@ -11,7 +11,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView from rest_framework.views import APIView
from rq.worker import Worker from rq.worker import Worker
from extras.plugins.utils import get_installed_plugins from netbox.plugins.utils import get_installed_plugins
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired

View File

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

View File

@ -15,7 +15,7 @@ DATABASE = {
} }
PLUGINS = [ PLUGINS = [
'extras.tests.dummy_plugin', 'netbox.tests.dummy_plugin',
] ]
REDIS = { REDIS = {

View File

@ -27,3 +27,12 @@ ADVISORY_LOCK_KEYS = {
'inventoryitem': 105700, 'inventoryitem': 105700,
'inventoryitemtemplate': 105800, 'inventoryitemtemplate': 105800,
} }
# Default view action permission mapping
DEFAULT_ACTION_PERMISSIONS = {
'add': {'add'},
'import': {'add'},
'export': {'view'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}

View File

@ -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()

View File

@ -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 netbox.registry import registry
from utilities.choices import ButtonColorChoices from utilities.choices import ButtonColorChoices

View File

@ -0,0 +1,156 @@
import collections
from importlib import import_module
from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
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 *
from .utils import *
# Initialize plugin registry
registry['plugins'].update({
'graphql_schemas': [],
'menus': [],
'menu_items': {},
'preferences': {},
'template_extensions': collections.defaultdict(list),
})
DEFAULT_RESOURCE_PATHS = {
'search_indexes': 'search.indexes',
'data_backends': 'data_backends.backends',
'graphql_schema': 'graphql.schema',
'menu': 'navigation.menu',
'menu_items': 'navigation.menu_items',
'template_extensions': 'template_content.template_extensions',
'user_preferences': 'preferences.preferences',
}
#
# Plugin AppConfig class
#
class PluginConfig(AppConfig):
"""
Subclass of Django's built-in AppConfig class, to be used for NetBox plugins.
"""
# Plugin metadata
author = ''
author_email = ''
description = ''
version = ''
# Root URL path under /plugins. If not set, the plugin's label will be used.
base_url = None
# Minimum/maximum compatible versions of NetBox
min_version = None
max_version = None
# Default configuration parameters
default_settings = {}
# Mandatory configuration parameters
required_settings = []
# Middleware classes provided by the plugin
middleware = []
# Django-rq queues dedicated to the plugin
queues = []
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
# Optional plugin resources
search_indexes = None
data_backends = None
graphql_schema = None
menu = None
menu_items = None
template_extensions = None
user_preferences = None
def _load_resource(self, name):
# Import from the configured path, if defined.
if path := getattr(self, name, None):
return import_string(f"{self.__module__}.{path}")
# Fall back to the resource's default path. Return None if the module has not been provided.
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
default_module, resource_name = default_path.rsplit('.', 1)
try:
module = import_module(default_module)
return getattr(module, resource_name, None)
except ModuleNotFoundError:
pass
def ready(self):
plugin_name = self.name.rsplit('.', 1)[-1]
# Register search extensions (if defined)
search_indexes = self._load_resource('search_indexes') or []
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)
# Register navigation menu and/or menu items (if defined)
if menu := self._load_resource('menu'):
register_menu(menu)
if menu_items := self._load_resource('menu_items'):
register_menu_items(self.verbose_name, menu_items)
# Register GraphQL schema (if defined)
if graphql_schema := self._load_resource('graphql_schema'):
register_graphql_schema(graphql_schema)
# Register user preferences (if defined)
if user_preferences := self._load_resource('user_preferences'):
register_user_preferences(plugin_name, user_preferences)
@classmethod
def validate(cls, user_config, netbox_version):
# Enforce version constraints
current_version = version.parse(netbox_version)
if cls.min_version is not None:
min_version = version.parse(cls.min_version)
if current_version < min_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
)
# Verify required configuration settings
for setting in cls.required_settings:
if setting not in user_config:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of "
f"configuration.py."
)
# Apply default configuration values
for setting, value in cls.default_settings.items():
if setting not in user_config:
user_config[setting] = value

View File

@ -0,0 +1,72 @@
from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices
from django.utils.text import slugify
__all__ = (
'PluginMenu',
'PluginMenuButton',
'PluginMenuItem',
)
class PluginMenu:
icon_class = 'mdi mdi-puzzle'
def __init__(self, label, groups, icon_class=None):
self.label = label
self.groups = [
MenuGroup(label, items) for label, items in groups
]
if icon_class is not None:
self.icon_class = icon_class
@property
def name(self):
return slugify(self.label)
class PluginMenuItem:
"""
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
specifying additional link buttons that appear to the right of the item in the van menu.
Links are specified as Django reverse URL strings.
Buttons are each specified as a list of PluginMenuButton instances.
"""
permissions = []
buttons = []
def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None):
self.link = link
self.link_text = link_text
self.staff_only = staff_only
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.")
self.permissions = permissions
if buttons is not None:
if type(buttons) not in (list, tuple):
raise TypeError("Buttons must be passed as a tuple or list.")
self.buttons = buttons
class PluginMenuButton:
"""
This class represents a button within a PluginMenuItem. Note that button colors should come from
ButtonColorChoices.
"""
color = ButtonColorChoices.DEFAULT
permissions = []
def __init__(self, link, title, icon_class, color=None, permissions=None):
self.link = link
self.title = title
self.icon_class = icon_class
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.")
self.permissions = permissions
if color is not None:
if color not in ButtonColorChoices.values():
raise ValueError("Button color must be a choice within ButtonColorChoices.")
self.color = color

View File

@ -0,0 +1,64 @@
import inspect
from netbox.registry import registry
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension
__all__ = (
'register_graphql_schema',
'register_menu',
'register_menu_items',
'register_template_extensions',
'register_user_preferences',
)
def register_template_extensions(class_list):
"""
Register a list of PluginTemplateExtension classes
"""
# Validation
for template_extension in class_list:
if not inspect.isclass(template_extension):
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
if not issubclass(template_extension, PluginTemplateExtension):
raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!")
if template_extension.model is None:
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
def register_menu(menu):
if not isinstance(menu, PluginMenu):
raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu")
registry['plugins']['menus'].append(menu)
def register_menu_items(section_name, class_list):
"""
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
"""
# Validation
for menu_link in class_list:
if not isinstance(menu_link, PluginMenuItem):
raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem")
for button in menu_link.buttons:
if not isinstance(button, PluginMenuButton):
raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton")
registry['plugins']['menu_items'][section_name] = class_list
def register_graphql_schema(graphql_schema):
"""
Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
"""
registry['plugins']['graphql_schemas'].append(graphql_schema)
def register_user_preferences(plugin_name, preferences):
"""
Register a list of user preferences defined by a plugin.
"""
registry['plugins']['preferences'][plugin_name] = preferences

View File

@ -0,0 +1,73 @@
from django.template.loader import get_template
__all__ = (
'PluginTemplateExtension',
)
class PluginTemplateExtension:
"""
This class is used to register plugin content to be injected into core NetBox templates. It contains methods
that are overridden by plugin authors to return template content.
The `model` attribute on the class defines the which model detail page this class renders content for. It
should be set as a string in the form '<app_label>.<model_name>'. render() provides the following context data:
* object - The object being viewed
* request - The current request
* settings - Global NetBox settings
* config - Plugin-specific configuration parameters
"""
model = None
def __init__(self, context):
self.context = context
def render(self, template_name, extra_context=None):
"""
Convenience method for rendering the specified Django template using the default context data. An additional
context dictionary may be passed as `extra_context`.
"""
if extra_context is None:
extra_context = {}
elif not isinstance(extra_context, dict):
raise TypeError("extra_context must be a dictionary")
return get_template(template_name).render({**self.context, **extra_context})
def left_page(self):
"""
Content that will be rendered on the left of the detail page view. Content should be returned as an
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
"""
raise NotImplementedError
def right_page(self):
"""
Content that will be rendered on the right of the detail page view. Content should be returned as an
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
"""
raise NotImplementedError
def full_width_page(self):
"""
Content that will be rendered within the full width of the detail page view. Content should be returned as an
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
"""
raise NotImplementedError
def buttons(self):
"""
Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
automatically handled.
"""
raise NotImplementedError
def list_buttons(self):
"""
Buttons that will be rendered and added to the existing list of buttons on the list view. Content
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
automatically handled.
"""
raise NotImplementedError

View File

@ -0,0 +1,41 @@
from importlib import import_module
from django.apps import apps
from django.conf import settings
from django.conf.urls import include
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
from django.utils.module_loading import import_string, module_has_submodule
from . import views
# Initialize URL base, API, and admin URL patterns for plugins
plugin_patterns = []
plugin_api_patterns = [
path('', views.PluginsAPIRootView.as_view(), name='api-root'),
path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list')
]
plugin_admin_patterns = [
path('installed-plugins/', staff_member_required(views.InstalledPluginsAdminView.as_view()), name='plugins_list')
]
# Register base/API URL patterns for each plugin
for plugin_path in settings.PLUGINS:
plugin = import_module(plugin_path)
plugin_name = plugin_path.split('.')[-1]
app = apps.get_app_config(plugin_name)
base_url = getattr(app, 'base_url') or app.label
# Check if the plugin specifies any base URLs
if module_has_submodule(plugin, 'urls'):
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
plugin_patterns.append(
path(f"{base_url}/", include((urlpatterns, app.label)))
)
# Check if the plugin specifies any API URLs
if module_has_submodule(plugin, 'api.urls'):
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
plugin_api_patterns.append(
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
)

View File

@ -0,0 +1,37 @@
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
__all__ = (
'get_installed_plugins',
'get_plugin_config',
)
def get_installed_plugins():
"""
Return a dictionary mapping the names of installed plugins to their versions.
"""
plugins = {}
for plugin_name in settings.PLUGINS:
plugin_name = plugin_name.rsplit('.', 1)[-1]
plugin_config = apps.get_app_config(plugin_name)
plugins[plugin_name] = getattr(plugin_config, 'version', None)
return dict(sorted(plugins.items()))
def get_plugin_config(plugin_name, parameter, default=None):
"""
Return the value of the specified plugin configuration parameter.
Args:
plugin_name: The name of the plugin
parameter: The name of the configuration parameter
default: The value to return if the parameter is not defined (default: None)
"""
try:
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
return plugin_config.get(parameter, default)
except KeyError:
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

View File

@ -0,0 +1,89 @@
from collections import OrderedDict
from django.apps import apps
from django.conf import settings
from django.shortcuts import render
from django.urls.exceptions import NoReverseMatch
from django.views.generic import View
from drf_spectacular.utils import extend_schema
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
class InstalledPluginsAdminView(View):
"""
Admin view for listing all installed plugins
"""
def get(self, request):
plugins = [apps.get_app_config(plugin) for plugin in settings.PLUGINS]
return render(request, 'extras/admin/plugins_list.html', {
'plugins': plugins,
})
@extend_schema(exclude=True)
class InstalledPluginsAPIView(APIView):
"""
API view for listing all installed plugins
"""
permission_classes = [permissions.IsAdminUser]
_ignore_model_permissions = True
schema = None
def get_view_name(self):
return "Installed Plugins"
@staticmethod
def _get_plugin_data(plugin_app_config):
return {
'name': plugin_app_config.verbose_name,
'package': plugin_app_config.name,
'author': plugin_app_config.author,
'author_email': plugin_app_config.author_email,
'description': plugin_app_config.description,
'version': plugin_app_config.version
}
def get(self, request, format=None):
return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS])
@extend_schema(exclude=True)
class PluginsAPIRootView(APIView):
_ignore_model_permissions = True
schema = None
def get_view_name(self):
return "Plugins"
@staticmethod
def _get_plugin_entry(plugin, app_config, request, format):
# Check if the plugin specifies any API URLs
api_app_name = f'{app_config.name}-api'
try:
entry = (getattr(app_config, 'base_url', app_config.label), reverse(
f"plugins-api:{api_app_name}:api-root",
request=request,
format=format
))
except NoReverseMatch:
# The plugin does not include an api-root url
entry = None
return entry
def get(self, request, format=None):
entries = []
for plugin in settings.PLUGINS:
app_config = apps.get_app_config(plugin)
entry = self._get_plugin_entry(plugin, app_config, request, format)
if entry is not None:
entries.append(entry)
return Response(OrderedDict((
('installed-plugins', reverse('plugins-api:plugins-list', request=request, format=format)),
*entries
)))

View File

@ -14,11 +14,11 @@ from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.utils.encoding import force_str from django.utils.encoding import force_str
from extras.plugins import PluginConfig
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from netbox.config import PARAMS from netbox.config import PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.plugins import PluginConfig
# #

View File

@ -1,8 +1,8 @@
from extras.plugins import PluginConfig from netbox.plugins import PluginConfig
class DummyPluginConfig(PluginConfig): class DummyPluginConfig(PluginConfig):
name = 'extras.tests.dummy_plugin' name = 'netbox.tests.dummy_plugin'
verbose_name = 'Dummy plugin' verbose_name = 'Dummy plugin'
version = '0.0' version = '0.0'
description = 'For testing purposes only' description = 'For testing purposes only'
@ -10,7 +10,7 @@ class DummyPluginConfig(PluginConfig):
min_version = '1.0' min_version = '1.0'
max_version = '9.0' max_version = '9.0'
middleware = [ middleware = [
'extras.tests.dummy_plugin.middleware.DummyMiddleware' 'netbox.tests.dummy_plugin.middleware.DummyMiddleware'
] ]
queues = [ queues = [
'testing-low', 'testing-low',

View File

@ -1,5 +1,5 @@
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from extras.tests.dummy_plugin.models import DummyModel from netbox.tests.dummy_plugin.models import DummyModel
class DummySerializer(ModelSerializer): class DummySerializer(ModelSerializer):

View File

@ -1,5 +1,5 @@
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from extras.tests.dummy_plugin.models import DummyModel from netbox.tests.dummy_plugin.models import DummyModel
from .serializers import DummySerializer from .serializers import DummySerializer

View File

@ -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,
)

View File

@ -1,5 +1,5 @@
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem from netbox.plugins.navigation import PluginMenu, PluginMenuButton, PluginMenuItem
items = ( items = (

View File

@ -1,4 +1,4 @@
from extras.plugins import PluginTemplateExtension from netbox.plugins.templates import PluginTemplateExtension
class SiteContent(PluginTemplateExtension): class SiteContent(PluginTemplateExtension):

View File

@ -5,22 +5,23 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from extras.plugins import PluginMenu from netbox.tests.dummy_plugin import config as dummy_config
from extras.tests.dummy_plugin import config as dummy_config from netbox.tests.dummy_plugin.data_backends import DummyBackend
from extras.plugins.utils import get_plugin_config from netbox.plugins.navigation import PluginMenu
from netbox.plugins.utils import get_plugin_config
from netbox.graphql.schema import Query from netbox.graphql.schema import Query
from netbox.registry import registry from netbox.registry import registry
@skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
class PluginTest(TestCase): class PluginTest(TestCase):
def test_config(self): def test_config(self):
self.assertIn('extras.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS)
def test_models(self): def test_models(self):
from extras.tests.dummy_plugin.models import DummyModel from netbox.tests.dummy_plugin.models import DummyModel
# Test saving an instance # Test saving an instance
instance = DummyModel(name='Instance 1', number=100) instance = DummyModel(name='Instance 1', number=100)
@ -92,7 +93,7 @@ class PluginTest(TestCase):
""" """
Check that plugin TemplateExtensions are registered. Check that plugin TemplateExtensions are registered.
""" """
from extras.tests.dummy_plugin.template_content import SiteContent from netbox.tests.dummy_plugin.template_content import SiteContent
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site']) self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
@ -109,15 +110,22 @@ class PluginTest(TestCase):
""" """
Check that plugin middleware is registered. Check that plugin middleware is registered.
""" """
self.assertIn('extras.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE) 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): def test_queues(self):
""" """
Check that plugin queues are registered with the accurate name. Check that plugin queues are registered with the accurate name.
""" """
self.assertIn('extras.tests.dummy_plugin.testing-low', settings.RQ_QUEUES) self.assertIn('netbox.tests.dummy_plugin.testing-low', settings.RQ_QUEUES)
self.assertIn('extras.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES) self.assertIn('netbox.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES)
self.assertIn('extras.tests.dummy_plugin.testing-high', settings.RQ_QUEUES) self.assertIn('netbox.tests.dummy_plugin.testing-high', settings.RQ_QUEUES)
def test_min_version(self): def test_min_version(self):
""" """
@ -170,17 +178,17 @@ class PluginTest(TestCase):
""" """
Validate the registration and operation of plugin-provided GraphQL schemas. Validate the registration and operation of plugin-provided GraphQL schemas.
""" """
from extras.tests.dummy_plugin.graphql import DummyQuery from netbox.tests.dummy_plugin.graphql import DummyQuery
self.assertIn(DummyQuery, registry['plugins']['graphql_schemas']) self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
self.assertTrue(issubclass(Query, DummyQuery)) self.assertTrue(issubclass(Query, DummyQuery))
@override_settings(PLUGINS_CONFIG={'extras.tests.dummy_plugin': {'foo': 123}}) @override_settings(PLUGINS_CONFIG={'netbox.tests.dummy_plugin': {'foo': 123}})
def test_get_plugin_config(self): def test_get_plugin_config(self):
""" """
Validate that get_plugin_config() returns config parameters correctly. Validate that get_plugin_config() returns config parameters correctly.
""" """
plugin = 'extras.tests.dummy_plugin' plugin = 'netbox.tests.dummy_plugin'
self.assertEqual(get_plugin_config(plugin, 'foo'), 123) self.assertEqual(get_plugin_config(plugin, 'foo'), 123)
self.assertEqual(get_plugin_config(plugin, 'bar'), None) self.assertEqual(get_plugin_config(plugin, 'bar'), None)
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)

View File

@ -6,10 +6,10 @@ from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from account.views import LoginView, LogoutView from account.views import LoginView, LogoutView
from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
from netbox.api.views import APIRootView, StatusView from netbox.api.views import APIRootView, StatusView
from netbox.graphql.schema import schema from netbox.graphql.schema import schema
from netbox.graphql.views import GraphQLView from netbox.graphql.views import GraphQLView
from netbox.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
from .admin import admin_site from .admin import admin_site

Some files were not shown because too many files have changed in this diff Show More