mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 07:56:44 -06:00
Merge branch 'feature' into 12135-protect-child-interfaces
This commit is contained in:
commit
2d4a5e935c
@ -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",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
@ -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`.
|
||||||
|
|
||||||
|
23
docs/plugins/development/data-backends.md
Normal file
23
docs/plugins/development/data-backends.md
Normal 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
|
@ -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`) |
|
||||||
|
@ -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'
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
18
netbox/circuits/migrations/0043_circuittype_color.py
Normal file
18
netbox/circuits/migrations/0043_circuittype_color.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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])
|
||||||
|
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
@ -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'
|
||||||
|
@ -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'),
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
18
netbox/core/migrations/0007_job_add_error_field.py
Normal file
18
netbox/core/migrations/0007_job_add_error_field.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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)"
|
||||||
})
|
})
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
20
netbox/core/tables/columns.py
Normal file
20
netbox/core/tables/columns.py
Normal 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
|
@ -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')
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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/'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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/'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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',
|
||||||
|
@ -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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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')
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -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={
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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])
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
@ -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',
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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}")
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
|
||||||
|
@ -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
|
|
||||||
|
@ -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
|
|
||||||
|
@ -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
|
|
||||||
|
@ -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")))
|
|
||||||
)
|
|
||||||
|
@ -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.")
|
|
||||||
|
@ -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
|
|
||||||
)))
|
|
||||||
|
@ -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()
|
||||||
|
@ -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}")
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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()
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ DATABASE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PLUGINS = [
|
PLUGINS = [
|
||||||
'extras.tests.dummy_plugin',
|
'netbox.tests.dummy_plugin',
|
||||||
]
|
]
|
||||||
|
|
||||||
REDIS = {
|
REDIS = {
|
||||||
|
@ -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'},
|
||||||
|
}
|
||||||
|
53
netbox/netbox/data_backends.py
Normal file
53
netbox/netbox/data_backends.py
Normal 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()
|
@ -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
|
||||||
|
156
netbox/netbox/plugins/__init__.py
Normal file
156
netbox/netbox/plugins/__init__.py
Normal 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
|
72
netbox/netbox/plugins/navigation.py
Normal file
72
netbox/netbox/plugins/navigation.py
Normal 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
|
64
netbox/netbox/plugins/registration.py
Normal file
64
netbox/netbox/plugins/registration.py
Normal 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
|
73
netbox/netbox/plugins/templates.py
Normal file
73
netbox/netbox/plugins/templates.py
Normal 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
|
41
netbox/netbox/plugins/urls.py
Normal file
41
netbox/netbox/plugins/urls.py
Normal 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")))
|
||||||
|
)
|
37
netbox/netbox/plugins/utils.py
Normal file
37
netbox/netbox/plugins/utils.py
Normal 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.")
|
89
netbox/netbox/plugins/views.py
Normal file
89
netbox/netbox/plugins/views.py
Normal 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
|
||||||
|
)))
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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',
|
@ -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):
|
@ -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
|
||||||
|
|
||||||
|
|
18
netbox/netbox/tests/dummy_plugin/data_backends.py
Normal file
18
netbox/netbox/tests/dummy_plugin/data_backends.py
Normal 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,
|
||||||
|
)
|
@ -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 = (
|
@ -1,4 +1,4 @@
|
|||||||
from extras.plugins import PluginTemplateExtension
|
from netbox.plugins.templates import PluginTemplateExtension
|
||||||
|
|
||||||
|
|
||||||
class SiteContent(PluginTemplateExtension):
|
class SiteContent(PluginTemplateExtension):
|
@ -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)
|
@ -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
Loading…
Reference in New Issue
Block a user