diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 744770180..48c14a2da 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.0 + placeholder: v3.6.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 5cf9b72ab..0525659ae 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.0 + placeholder: v3.6.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml new file mode 100644 index 000000000..d07bc399d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -0,0 +1,37 @@ +--- +name: 🌍 Translation +description: Request support for a new language in the user interface +labels: ["type: translation"] +body: + - type: markdown + attributes: + value: > + **NOTE:** This template is used only for proposing the addition of *new* languages. Please do + not use it to request changes to existing translations. + - type: input + attributes: + label: Language + description: What is the name of the language in English? + validations: + required: true + - type: input + attributes: + label: ISO 639-1 code + description: > + What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) + assigned to the language? + validations: + required: true + - type: dropdown + attributes: + label: Volunteer + description: Are you a fluent speaker of this language **and** willing to contribute a translation map? + options: + - "Yes" + - "No" + validations: + required: true + - type: textarea + attributes: + label: Comments + description: Any other notes you would like to share diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d9692194..9d580baa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,15 +31,15 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -47,7 +47,7 @@ jobs: run: npm install -g yarn - name: Setup Node.js with Yarn Caching - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 6019cef5d..a3e66a429 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,7 +14,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v3 + - uses: dessant/lock-threads@v4 with: issue-inactive-days: 90 pr-inactive-days: 30 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3b37aae56..22de146a2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v6 + - uses: actions/stale@v8 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an diff --git a/README.md b/README.md index 54b3e727e..6e50e5687 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
The premiere source of truth powering network automation
+The premier source of truth powering network automation
00ff00
'),
+ }
class CircuitImportForm(NetBoxModelImportForm):
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index 1fb239023..a82ec1726 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
-from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
@@ -88,7 +88,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
label=_('Provider')
)
service_id = forms.CharField(
- label=_('Service id'),
+ label=_('Service ID'),
max_length=100,
required=False
)
@@ -97,8 +97,17 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
model = CircuitType
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Attributes'), ('color',)),
+ )
tag = TagFilterField(model)
+ color = ColorField(
+ label=_('Color'),
+ required=False
+ )
+
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Circuit
diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py
index 8a540032e..0809cb2f4 100644
--- a/netbox/circuits/forms/model_forms.py
+++ b/netbox/circuits/forms/model_forms.py
@@ -76,14 +76,14 @@ class CircuitTypeForm(NetBoxModelForm):
fieldsets = (
(_('Circuit Type'), (
- 'name', 'slug', 'description', 'tags',
+ 'name', 'slug', 'color', 'description', 'tags',
)),
)
class Meta:
model = CircuitType
fields = [
- 'name', 'slug', 'description', 'tags',
+ 'name', 'slug', 'color', 'description', 'tags',
]
diff --git a/netbox/circuits/migrations/0043_circuittype_color.py b/netbox/circuits/migrations/0043_circuittype_color.py
new file mode 100644
index 000000000..6c4dffeb6
--- /dev/null
+++ b/netbox/circuits/migrations/0043_circuittype_color.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.5 on 2023-10-20 21:25
+
+from django.db import migrations
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('circuits', '0042_provideraccount'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='circuittype',
+ name='color',
+ field=utilities.fields.ColorField(blank=True, max_length=6),
+ ),
+ ]
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index 0322b67c6..4dc775364 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -7,6 +7,7 @@ from circuits.choices import *
from dcim.models import CabledObjectModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
+from utilities.fields import ColorField
__all__ = (
'Circuit',
@@ -20,6 +21,11 @@ class CircuitType(OrganizationalModel):
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
+ color = ColorField(
+ verbose_name=_('color'),
+ blank=True
+ )
+
def get_absolute_url(self):
return reverse('circuits:circuittype', args=[self.pk])
diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py
index b80f92d4d..c22b400eb 100644
--- a/netbox/circuits/search.py
+++ b/netbox/circuits/search.py
@@ -10,6 +10,7 @@ class CircuitIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
@register_search
@@ -22,6 +23,7 @@ class CircuitTerminationIndex(SearchIndex):
('port_speed', 2000),
('upstream_speed', 2000),
)
+ display_attrs = ('circuit', 'site', 'provider_network', 'description')
@register_search
@@ -32,6 +34,7 @@ class CircuitTypeIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('description',)
@register_search
@@ -42,6 +45,7 @@ class ProviderIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('description',)
class ProviderAccountIndex(SearchIndex):
@@ -51,6 +55,7 @@ class ProviderAccountIndex(SearchIndex):
('account', 200),
('comments', 5000),
)
+ display_attrs = ('provider', 'account', 'description')
@register_search
@@ -62,3 +67,4 @@ class ProviderNetworkIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('provider', 'service_id', 'description')
diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py
index 6a05983e6..6ae727eca 100644
--- a/netbox/circuits/tables/circuits.py
+++ b/netbox/circuits/tables/circuits.py
@@ -28,6 +28,7 @@ class CircuitTypeTable(NetBoxTable):
linkify=True,
verbose_name=_('Name'),
)
+ color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='circuits:circuittype_list'
)
@@ -40,7 +41,7 @@ class CircuitTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = CircuitType
fields = (
- 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
+ 'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py
index 4117a609c..4ae426df5 100644
--- a/netbox/core/api/serializers.py
+++ b/netbox/core/api/serializers.py
@@ -4,6 +4,7 @@ from core.choices import *
from core.models import *
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
+from netbox.utils import get_data_backend_choices
from users.api.nested_serializers import NestedUserSerializer
from .nested_serializers import *
@@ -19,7 +20,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
view_name='core-api:datasource-detail'
)
type = ChoiceField(
- choices=DataSourceTypeChoices
+ choices=get_data_backend_choices()
)
status = ChoiceField(
choices=DataSourceStatusChoices,
@@ -68,5 +69,5 @@ class JobSerializer(BaseModelSerializer):
model = Job
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
- 'started', 'completed', 'user', 'data', 'job_id',
+ 'started', 'completed', 'user', 'data', 'error', 'job_id',
]
diff --git a/netbox/core/apps.py b/netbox/core/apps.py
index ffcf0b4ea..2d999c57e 100644
--- a/netbox/core/apps.py
+++ b/netbox/core/apps.py
@@ -1,4 +1,15 @@
from django.apps import AppConfig
+from django.db import models
+from django.db.migrations.operations import AlterModelOptions
+
+from utilities.migration import custom_deconstruct
+
+# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
+AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
+AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
+
+# Use our custom destructor to ignore certain attributes when calculating field migrations
+models.Field.deconstruct = custom_deconstruct
class CoreConfig(AppConfig):
diff --git a/netbox/core/choices.py b/netbox/core/choices.py
index 0067dfed8..8d7050414 100644
--- a/netbox/core/choices.py
+++ b/netbox/core/choices.py
@@ -7,18 +7,6 @@ from utilities.choices import ChoiceSet
# Data sources
#
-class DataSourceTypeChoices(ChoiceSet):
- LOCAL = 'local'
- GIT = 'git'
- AMAZON_S3 = 'amazon-s3'
-
- CHOICES = (
- (LOCAL, _('Local'), 'gray'),
- (GIT, _('Git'), 'blue'),
- (AMAZON_S3, _('Amazon S3'), 'blue'),
- )
-
-
class DataSourceStatusChoices(ChoiceSet):
NEW = 'new'
QUEUED = 'queued'
diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py
index d2dacbbe0..9ff0b4d63 100644
--- a/netbox/core/data_backends.py
+++ b/netbox/core/data_backends.py
@@ -10,61 +10,24 @@ from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
-from netbox.registry import registry
-from .choices import DataSourceTypeChoices
+from netbox.data_backends import DataBackend
+from netbox.utils import register_data_backend
from .exceptions import SyncError
__all__ = (
- 'LocalBackend',
'GitBackend',
+ 'LocalBackend',
'S3Backend',
)
logger = logging.getLogger('netbox.data_backends')
-def register_backend(name):
- """
- Decorator for registering a DataBackend class.
- """
-
- def _wrapper(cls):
- registry['data_backends'][name] = cls
- return cls
-
- return _wrapper
-
-
-class DataBackend:
- parameters = {}
- sensitive_parameters = []
-
- # Prevent Django's template engine from calling the backend
- # class when referenced via DataSource.backend_class
- do_not_call_in_templates = True
-
- def __init__(self, url, **kwargs):
- self.url = url
- self.params = kwargs
- self.config = self.init_config()
-
- def init_config(self):
- """
- Hook to initialize the instance's configuration.
- """
- return
-
- @property
- def url_scheme(self):
- return urlparse(self.url).scheme.lower()
-
- @contextmanager
- def fetch(self):
- raise NotImplemented()
-
-
-@register_backend(DataSourceTypeChoices.LOCAL)
+@register_data_backend()
class LocalBackend(DataBackend):
+ name = 'local'
+ label = _('Local')
+ is_local = True
@contextmanager
def fetch(self):
@@ -74,20 +37,22 @@ class LocalBackend(DataBackend):
yield local_path
-@register_backend(DataSourceTypeChoices.GIT)
+@register_data_backend()
class GitBackend(DataBackend):
+ name = 'git'
+ label = 'Git'
parameters = {
'username': forms.CharField(
required=False,
label=_('Username'),
widget=forms.TextInput(attrs={'class': 'form-control'}),
- help_text=_("Only used for cloning with HTTP / HTTPS"),
+ help_text=_("Only used for cloning with HTTP(S)"),
),
'password': forms.CharField(
required=False,
label=_('Password'),
widget=forms.TextInput(attrs={'class': 'form-control'}),
- help_text=_("Only used for cloning with HTTP / HTTPS"),
+ help_text=_("Only used for cloning with HTTP(S)"),
),
'branch': forms.CharField(
required=False,
@@ -144,8 +109,10 @@ class GitBackend(DataBackend):
local_path.cleanup()
-@register_backend(DataSourceTypeChoices.AMAZON_S3)
+@register_data_backend()
class S3Backend(DataBackend):
+ name = 'amazon-s3'
+ label = 'Amazon S3'
parameters = {
'aws_access_key_id': forms.CharField(
label=_('AWS access key ID'),
diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py
index 62a58086a..410e2e80c 100644
--- a/netbox/core/filtersets.py
+++ b/netbox/core/filtersets.py
@@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
+from netbox.utils import get_data_backend_choices
from .choices import *
from .models import *
@@ -16,7 +17,7 @@ __all__ = (
class DataSourceFilterSet(NetBoxModelFilterSet):
type = django_filters.MultipleChoiceFilter(
- choices=DataSourceTypeChoices,
+ choices=get_data_backend_choices,
null_value=None
)
status = django_filters.MultipleChoiceFilter(
diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py
index a4ecd646f..dcc92c6f0 100644
--- a/netbox/core/forms/bulk_edit.py
+++ b/netbox/core/forms/bulk_edit.py
@@ -1,10 +1,9 @@
from django import forms
from django.utils.translation import gettext_lazy as _
-from core.choices import DataSourceTypeChoices
from core.models import *
from netbox.forms import NetBoxModelBulkEditForm
-from utilities.forms import add_blank_choice
+from netbox.utils import get_data_backend_choices
from utilities.forms.fields import CommentField
from utilities.forms.widgets import BulkEditNullBooleanSelect
@@ -16,9 +15,8 @@ __all__ = (
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField(
label=_('Type'),
- choices=add_blank_choice(DataSourceTypeChoices),
- required=False,
- initial=''
+ choices=get_data_backend_choices,
+ required=False
)
enabled = forms.NullBooleanField(
required=False,
diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py
index f7a6f3595..a567a9fed 100644
--- a/netbox/core/forms/filtersets.py
+++ b/netbox/core/forms/filtersets.py
@@ -1,13 +1,12 @@
from django import forms
from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from core.choices import *
from core.models import *
from extras.forms.mixins import SavedFiltersMixin
-from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelFilterSetForm
+from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
@@ -27,7 +26,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
)
type = forms.MultipleChoiceField(
label=_('Type'),
- choices=DataSourceTypeChoices,
+ choices=get_data_backend_choices,
required=False
)
status = forms.MultipleChoiceField(
@@ -68,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
)
object_type = ContentTypeChoiceField(
label=_('Object Type'),
- queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()),
+ queryset=ContentType.objects.with_feature('jobs'),
required=False,
)
status = forms.MultipleChoiceField(
diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py
index 01d5474c6..e3184acf6 100644
--- a/netbox/core/forms/model_forms.py
+++ b/netbox/core/forms/model_forms.py
@@ -7,6 +7,7 @@ from core.forms.mixins import SyncedDataMixin
from core.models import *
from netbox.forms import NetBoxModelForm
from netbox.registry import registry
+from netbox.utils import get_data_backend_choices
from utilities.forms import get_field_value
from utilities.forms.fields import CommentField
from utilities.forms.widgets import HTMXSelect
@@ -18,6 +19,10 @@ __all__ = (
class DataSourceForm(NetBoxModelForm):
+ type = forms.ChoiceField(
+ choices=get_data_backend_choices,
+ widget=HTMXSelect()
+ )
comments = CommentField()
class Meta:
@@ -26,7 +31,6 @@ class DataSourceForm(NetBoxModelForm):
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
]
widgets = {
- 'type': HTMXSelect(),
'ignore_rules': forms.Textarea(
attrs={
'rows': 5,
@@ -56,12 +60,13 @@ class DataSourceForm(NetBoxModelForm):
# Add backend-specific form fields
self.backend_fields = []
- for name, form_field in backend.parameters.items():
- field_name = f'backend_{name}'
- self.backend_fields.append(field_name)
- self.fields[field_name] = copy.copy(form_field)
- if self.instance and self.instance.parameters:
- self.fields[field_name].initial = self.instance.parameters.get(name)
+ if backend:
+ for name, form_field in backend.parameters.items():
+ field_name = f'backend_{name}'
+ self.backend_fields.append(field_name)
+ self.fields[field_name] = copy.copy(form_field)
+ if self.instance and self.instance.parameters:
+ self.fields[field_name].initial = self.instance.parameters.get(name)
def save(self, *args, **kwargs):
diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py
index d25981920..32b546b20 100644
--- a/netbox/core/jobs.py
+++ b/netbox/core/jobs.py
@@ -25,7 +25,7 @@ def sync_datasource(job, *args, **kwargs):
job.terminate()
except Exception as e:
- job.terminate(status=JobStatusChoices.STATUS_ERRORED)
+ job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) in (SyncError, JobTimeoutException):
logging.error(e)
diff --git a/netbox/core/management/commands/clearcache.py b/netbox/core/management/commands/clearcache.py
index 22843c490..dd95013af 100644
--- a/netbox/core/management/commands/clearcache.py
+++ b/netbox/core/management/commands/clearcache.py
@@ -1,11 +1,20 @@
from django.core.cache import cache
from django.core.management.base import BaseCommand
+from extras.models import ConfigRevision
+
class Command(BaseCommand):
"""Command to clear the entire cache."""
help = 'Clears the cache.'
def handle(self, *args, **kwargs):
+ # Fetch the current config revision from the cache
+ config_version = cache.get('config_version')
+ # Clear the cache
cache.clear()
self.stdout.write('Cache has been cleared.', ending="\n")
+ if config_version:
+ # Activate the current config revision
+ ConfigRevision.objects.get(id=config_version).activate()
+ self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")
diff --git a/netbox/core/management/commands/makemigrations.py b/netbox/core/management/commands/makemigrations.py
index 10874418a..ce40bd3cc 100644
--- a/netbox/core/management/commands/makemigrations.py
+++ b/netbox/core/management/commands/makemigrations.py
@@ -1,18 +1,6 @@
-# noinspection PyUnresolvedReferences
from django.conf import settings
from django.core.management.base import CommandError
from django.core.management.commands.makemigrations import Command as _Command
-from django.db import models
-from django.db.migrations.operations import AlterModelOptions
-
-from utilities.migration import custom_deconstruct
-
-# Monkey patch AlterModelOptions to ignore verbose name attributes
-AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
-AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
-
-# Set our custom deconstructor for fields
-models.Field.deconstruct = custom_deconstruct
class Command(_Command):
diff --git a/netbox/core/management/commands/migrate.py b/netbox/core/management/commands/migrate.py
deleted file mode 100644
index 8d5e45a40..000000000
--- a/netbox/core/management/commands/migrate.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# noinspection PyUnresolvedReferences
-from django.core.management.commands.migrate import Command
-from django.db import models
-
-from utilities.migration import custom_deconstruct
-
-models.Field.deconstruct = custom_deconstruct
diff --git a/netbox/core/migrations/0003_job.py b/netbox/core/migrations/0003_job.py
index ab6f058ff..f2fe41afb 100644
--- a/netbox/core/migrations/0003_job.py
+++ b/netbox/core/migrations/0003_job.py
@@ -4,7 +4,6 @@ from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
-import extras.utils
class Migration(migrations.Migration):
@@ -30,7 +29,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default='pending', max_length=30)),
('data', models.JSONField(blank=True, null=True)),
('job_id', models.UUIDField(unique=True)),
- ('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
+ ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py
new file mode 100644
index 000000000..0ad8d8854
--- /dev/null
+++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.6 on 2023-10-20 17:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0005_job_created_auto_now'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='datasource',
+ name='type',
+ field=models.CharField(max_length=50),
+ ),
+ ]
diff --git a/netbox/core/migrations/0007_job_add_error_field.py b/netbox/core/migrations/0007_job_add_error_field.py
new file mode 100644
index 000000000..e2e173bfd
--- /dev/null
+++ b/netbox/core/migrations/0007_job_add_error_field.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.6 on 2023-10-23 20:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0006_datasource_type_remove_choices'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='job',
+ name='error',
+ field=models.TextField(blank=True, editable=False),
+ ),
+ ]
diff --git a/netbox/core/migrations/0008_contenttype_proxy.py b/netbox/core/migrations/0008_contenttype_proxy.py
new file mode 100644
index 000000000..ac11d906a
--- /dev/null
+++ b/netbox/core/migrations/0008_contenttype_proxy.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.6 on 2023-10-31 19:38
+
+import core.models.contenttypes
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('core', '0007_job_add_error_field'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContentType',
+ fields=[
+ ],
+ options={
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('contenttypes.contenttype',),
+ managers=[
+ ('objects', core.models.contenttypes.ContentTypeManager()),
+ ],
+ ),
+ ]
diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py
index 185622f5f..c93c392d7 100644
--- a/netbox/core/models/__init__.py
+++ b/netbox/core/models/__init__.py
@@ -1,3 +1,4 @@
+from .contenttypes import *
from .data import *
from .files import *
from .jobs import *
diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py
new file mode 100644
index 000000000..0731871ec
--- /dev/null
+++ b/netbox/core/models/contenttypes.py
@@ -0,0 +1,50 @@
+from django.contrib.contenttypes.models import ContentType as ContentType_, ContentTypeManager as ContentTypeManager_
+from django.db.models import Q
+
+from netbox.registry import registry
+
+__all__ = (
+ 'ContentType',
+ 'ContentTypeManager',
+)
+
+
+class ContentTypeManager(ContentTypeManager_):
+
+ def public(self):
+ """
+ Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed
+ in registry['models'] and intended for reference by other objects.
+ """
+ q = Q()
+ for app_label, models in registry['models'].items():
+ q |= Q(app_label=app_label, model__in=models)
+ return self.get_queryset().filter(q)
+
+ def with_feature(self, feature):
+ """
+ Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
+ we can find all ContentTypes for models which support webhooks with
+
+ ContentType.objects.with_feature('webhooks')
+ """
+ if feature not in registry['model_features']:
+ raise KeyError(
+ f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
+ )
+
+ q = Q()
+ for app_label, models in registry['model_features'][feature].items():
+ q |= Q(app_label=app_label, model__in=models)
+
+ return self.get_queryset().filter(q)
+
+
+class ContentType(ContentType_):
+ """
+ Wrap Django's native ContentType model to use our custom manager.
+ """
+ objects = ContentTypeManager()
+
+ class Meta:
+ proxy = True
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 8e372c2eb..cf40c0bd5 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -6,7 +6,6 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
@@ -45,9 +44,7 @@ class DataSource(JobsMixin, PrimaryModel):
)
type = models.CharField(
verbose_name=_('type'),
- max_length=50,
- choices=DataSourceTypeChoices,
- default=DataSourceTypeChoices.LOCAL
+ max_length=50
)
source_url = models.CharField(
max_length=200,
@@ -96,8 +93,9 @@ class DataSource(JobsMixin, PrimaryModel):
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
- def get_type_color(self):
- return DataSourceTypeChoices.colors.get(self.type)
+ def get_type_display(self):
+ if backend := registry['data_backends'].get(self.type):
+ return backend.label
def get_status_color(self):
return DataSourceStatusChoices.colors.get(self.status)
@@ -110,10 +108,6 @@ class DataSource(JobsMixin, PrimaryModel):
def backend_class(self):
return registry['data_backends'].get(self.type)
- @property
- def is_local(self):
- return self.type == DataSourceTypeChoices.LOCAL
-
@property
def ready_for_sync(self):
return self.enabled and self.status not in (
@@ -123,8 +117,14 @@ class DataSource(JobsMixin, PrimaryModel):
def clean(self):
+ # Validate data backend type
+ if self.type and self.type not in registry['data_backends']:
+ raise ValidationError({
+ 'type': _("Unknown backend type: {type}".format(type=self.type))
+ })
+
# Ensure URL scheme matches selected type
- if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
+ if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
})
@@ -316,7 +316,7 @@ class DataFile(models.Model):
if not self.data:
return None
try:
- return bytes(self.data, 'utf-8')
+ return self.data.decode('utf-8')
except UnicodeDecodeError:
return None
@@ -367,7 +367,7 @@ class AutoSyncRecord(models.Model):
related_name='+'
)
object_type = models.ForeignKey(
- to=ContentType,
+ to='contenttypes.ContentType',
on_delete=models.CASCADE,
related_name='+'
)
@@ -377,6 +377,8 @@ class AutoSyncRecord(models.Model):
fk_field='object_id'
)
+ _netbox_private = True
+
class Meta:
constraints = (
models.UniqueConstraint(
diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py
index 38d82463e..138527581 100644
--- a/netbox/core/models/files.py
+++ b/netbox/core/models/files.py
@@ -44,6 +44,7 @@ class ManagedFile(SyncedDataMixin, models.Model):
)
objects = RestrictedQuerySet.as_manager()
+ _netbox_private = True
class Meta:
ordering = ('file_root', 'file_path')
diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py
index 61b0e64fa..5b9b41e53 100644
--- a/netbox/core/models/jobs.py
+++ b/netbox/core/models/jobs.py
@@ -3,7 +3,7 @@ import uuid
import django_rq
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.urls import reverse
@@ -11,8 +11,8 @@ from django.utils import timezone
from django.utils.translation import gettext as _
from core.choices import JobStatusChoices
+from core.models import ContentType
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
-from extras.utils import FeatureQuery
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet
@@ -28,9 +28,8 @@ class Job(models.Model):
Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
"""
object_type = models.ForeignKey(
- to=ContentType,
+ to='contenttypes.ContentType',
related_name='jobs',
- limit_choices_to=FeatureQuery('jobs'),
on_delete=models.CASCADE,
)
object_id = models.PositiveBigIntegerField(
@@ -92,6 +91,11 @@ class Job(models.Model):
null=True,
blank=True
)
+ error = models.TextField(
+ verbose_name=_('error'),
+ editable=False,
+ blank=True
+ )
job_id = models.UUIDField(
verbose_name=_('job ID'),
unique=True
@@ -118,6 +122,15 @@ class Job(models.Model):
def get_status_color(self):
return JobStatusChoices.colors.get(self.status)
+ def clean(self):
+ super().clean()
+
+ # Validate the assigned object type
+ if self.object_type not in ContentType.objects.with_feature('jobs'):
+ raise ValidationError(
+ _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
+ )
+
@property
def duration(self):
if not self.completed:
@@ -158,7 +171,7 @@ class Job(models.Model):
# Handle webhooks
self.trigger_webhooks(event=EVENT_JOB_START)
- def terminate(self, status=JobStatusChoices.STATUS_COMPLETED):
+ def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
"""
Mark the job as completed, optionally specifying a particular termination status.
"""
@@ -168,6 +181,8 @@ class Job(models.Model):
# Mark the job as completed
self.status = status
+ if error:
+ self.error = error
self.completed = timezone.now()
self.save()
diff --git a/netbox/core/search.py b/netbox/core/search.py
index e6d3005e6..5ea9db761 100644
--- a/netbox/core/search.py
+++ b/netbox/core/search.py
@@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('type', 'status', 'description')
@register_search
diff --git a/netbox/core/tables/columns.py b/netbox/core/tables/columns.py
new file mode 100644
index 000000000..93f1e3901
--- /dev/null
+++ b/netbox/core/tables/columns.py
@@ -0,0 +1,20 @@
+import django_tables2 as tables
+
+from netbox.registry import registry
+
+__all__ = (
+ 'BackendTypeColumn',
+)
+
+
+class BackendTypeColumn(tables.Column):
+ """
+ Display a data backend type.
+ """
+ def render(self, value):
+ if backend := registry['data_backends'].get(value):
+ return backend.label
+ return value
+
+ def value(self, value):
+ return value
diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py
index 1ecc42369..4059ea9bc 100644
--- a/netbox/core/tables/data.py
+++ b/netbox/core/tables/data.py
@@ -3,6 +3,7 @@ import django_tables2 as tables
from core.models import *
from netbox.tables import NetBoxTable, columns
+from .columns import BackendTypeColumn
__all__ = (
'DataFileTable',
@@ -15,8 +16,8 @@ class DataSourceTable(NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
- type = columns.ChoiceFieldColumn(
- verbose_name=_('Type'),
+ type = BackendTypeColumn(
+ verbose_name=_('Type')
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
@@ -34,8 +35,8 @@ class DataSourceTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = DataSource
fields = (
- 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
- 'last_updated', 'file_count',
+ 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
+ 'created', 'last_updated', 'file_count',
)
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py
index 32ca67f7f..ac27224b3 100644
--- a/netbox/core/tables/jobs.py
+++ b/netbox/core/tables/jobs.py
@@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
)
object = tables.Column(
verbose_name=_('Object'),
- linkify=True
+ linkify=True,
+ orderable=False
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
@@ -47,7 +48,7 @@ class JobTable(NetBoxTable):
model = Job
fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
- 'completed', 'user', 'job_id',
+ 'completed', 'user', 'error', 'job_id',
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py
index dc6d6a5ce..cd25761f0 100644
--- a/netbox/core/tests/test_api.py
+++ b/netbox/core/tests/test_api.py
@@ -2,7 +2,6 @@ from django.urls import reverse
from django.utils import timezone
from utilities.testing import APITestCase, APIViewTestCases
-from ..choices import *
from ..models import *
@@ -26,26 +25,26 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
data_sources = (
- DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
- DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
- DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+ DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
+ DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
+ DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
cls.create_data = [
{
'name': 'Data Source 4',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'https://example.com/git/source4'
},
{
'name': 'Data Source 5',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'https://example.com/git/source5'
},
{
'name': 'Data Source 6',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'https://example.com/git/source6'
},
]
@@ -63,7 +62,7 @@ class DataFileTest(
def setUpTestData(cls):
datasource = DataSource.objects.create(
name='Data Source 1',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source1/'
)
diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py
index e1e916f70..2f60c7522 100644
--- a/netbox/core/tests/test_filtersets.py
+++ b/netbox/core/tests/test_filtersets.py
@@ -18,21 +18,21 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
data_sources = (
DataSource(
name='Data Source 1',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source1/',
status=DataSourceStatusChoices.NEW,
enabled=True
),
DataSource(
name='Data Source 2',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source2/',
status=DataSourceStatusChoices.SYNCING,
enabled=True
),
DataSource(
name='Data Source 3',
- type=DataSourceTypeChoices.GIT,
+ type='git',
source_url='https://example.com/git/source3',
status=DataSourceStatusChoices.COMPLETED,
enabled=False
@@ -45,7 +45,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
- params = {'type': [DataSourceTypeChoices.LOCAL]}
+ params = {'type': ['local']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
@@ -66,9 +66,9 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
data_sources = (
- DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
- DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
- DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+ DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
+ DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
+ DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py
index 4a50a8d05..16d07f376 100644
--- a/netbox/core/tests/test_views.py
+++ b/netbox/core/tests/test_views.py
@@ -1,7 +1,6 @@
from django.utils import timezone
from utilities.testing import ViewTestCases, create_tags
-from ..choices import *
from ..models import *
@@ -11,9 +10,9 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
data_sources = (
- DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
- DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
- DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+ DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
+ DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
+ DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
@@ -21,7 +20,7 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'name': 'Data Source X',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'http:///exmaple/com/foo/bar/',
'description': 'Something',
'comments': 'Foo bar baz',
@@ -29,10 +28,10 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- f"name,type,source_url,enabled",
- f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
- f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
- f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false",
+ "name,type,source_url,enabled",
+ "Data Source 4,local,file:///var/tmp/source4/,true",
+ "Data Source 5,local,file:///var/tmp/source4/,true",
+ "Data Source 6,git,http:///exmaple/com/foo/bar/,false",
)
cls.csv_update_data = (
@@ -60,7 +59,7 @@ class DataFileTestCase(
def setUpTestData(cls):
datasource = DataSource.objects.create(
name='Data Source 1',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source1/'
)
diff --git a/netbox/core/views.py b/netbox/core/views.py
index c7c593770..d16fa4ece 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -2,6 +2,7 @@ from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from extras.models import ConfigRevision
+from netbox.config import get_config
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from utilities.utils import count_related
@@ -99,7 +100,9 @@ class DataFileListView(generic.ObjectListView):
filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable
- actions = ('bulk_delete',)
+ actions = {
+ 'bulk_delete': {'delete'},
+ }
@register_model_view(DataFile)
@@ -127,7 +130,10 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm
table = tables.JobTable
- actions = ('export', 'delete', 'bulk_delete')
+ actions = {
+ 'export': {'view'},
+ 'bulk_delete': {'delete'},
+ }
class JobView(generic.ObjectView):
@@ -152,4 +158,9 @@ class ConfigView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_object(self, **kwargs):
- return self.queryset.first()
+ if config := self.queryset.first():
+ return config
+ # Instantiate a dummy default config if none has been created yet
+ return ConfigRevision(
+ data=get_config().defaults
+ )
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 2f4eb6581..32dcdc5bb 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
model = DeviceType
fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
- 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
- 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
- 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
+ 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+ 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count',
@@ -787,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
]
-class DeviceNAPALMSerializer(serializers.Serializer):
- method = serializers.JSONField()
-
-
#
# Device components
#
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index f045f1bb4..cd5a297c9 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -3,10 +3,8 @@ from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action
-from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
-from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
@@ -14,16 +12,16 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
-from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
+from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
-from netbox.api.renderers import TextRenderer
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
+from utilities.query_functions import CollateAsChar
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
@@ -98,7 +96,7 @@ class PassThroughPortMixin(object):
# Regions
#
-class RegionViewSet(NetBoxModelViewSet):
+class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
@@ -114,7 +112,7 @@ class RegionViewSet(NetBoxModelViewSet):
# Site groups
#
-class SiteGroupViewSet(NetBoxModelViewSet):
+class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(),
Site,
@@ -149,7 +147,7 @@ class SiteViewSet(NetBoxModelViewSet):
# Locations
#
-class LocationViewSet(NetBoxModelViewSet):
+class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
@@ -350,7 +348,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
filterset_class = filtersets.DeviceBayTemplateFilterSet
-class InventoryItemTemplateViewSet(NetBoxModelViewSet):
+class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
serializer_class = serializers.InventoryItemTemplateSerializer
filterset_class = filtersets.InventoryItemTemplateFilterSet
@@ -389,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
- ConfigTemplateRenderMixin,
+ RenderConfigMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related(
@@ -419,23 +417,6 @@ class DeviceViewSet(
return serializers.DeviceWithConfigContextSerializer
- @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
- def render_config(self, request, pk):
- """
- Resolve and render the preferred ConfigTemplate for this Device.
- """
- device = self.get_object()
- configtemplate = device.get_config_template()
- if not configtemplate:
- return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
-
- # Compile context data
- context_data = device.get_config_context()
- context_data.update(request.data)
- context_data.update({'device': device})
-
- return self.render_configtemplate(request, configtemplate, context_data)
-
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related(
@@ -505,6 +486,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
filterset_class = filtersets.InterfaceFilterSet
brief_prefetch_fields = ['device']
+ def get_bulk_destroy_queryset(self):
+ # Ensure child interfaces are deleted prior to their parents
+ return self.get_queryset().order_by('device', 'parent', CollateAsChar('_name'))
+
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related(
@@ -538,7 +523,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
brief_prefetch_fields = ['device']
-class InventoryItemViewSet(NetBoxModelViewSet):
+class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
serializer_class = serializers.InventoryItemSerializer
filterset_class = filtersets.InventoryItemFilterSet
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 1bcf61b20..2ba24e0aa 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -80,10 +80,10 @@ class RackWidthChoices(ChoiceSet):
WIDTH_23IN = 23
CHOICES = (
- (WIDTH_10IN, _('10 inches')),
- (WIDTH_19IN, _('19 inches')),
- (WIDTH_21IN, _('21 inches')),
- (WIDTH_23IN, _('23 inches')),
+ (WIDTH_10IN, _('{n} inches').format(n=10)),
+ (WIDTH_19IN, _('{n} inches').format(n=19)),
+ (WIDTH_21IN, _('{n} inches').format(n=21)),
+ (WIDTH_23IN, _('{n} inches').format(n=23)),
)
@@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
+ TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
+ TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
@@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
+ (TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
+ (TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 0261998db..ffd3879a8 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
+from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, L2VPN, IPAddress, VRF
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@@ -496,7 +497,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = DeviceType
fields = [
- 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+ 'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role',
+ 'airflow', 'weight', 'weight_unit',
]
def search(self, queryset, name, value):
@@ -817,7 +819,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
fields = ['id', 'name', 'slug', 'description']
-class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(
+ NetBoxModelFilterSet,
+ TenancyFilterSet,
+ ContactModelFilterSet,
+ LocalConfigContextFilterSet,
+ PrimaryIPFilterSet,
+):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
@@ -993,16 +1001,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_device_bays',
label=_('Has device bays'),
)
- primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
- field_name='primary_ip4',
- queryset=IPAddress.objects.all(),
- label=_('Primary IPv4 (ID)'),
- )
- primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
- field_name='primary_ip6',
- queryset=IPAddress.objects.all(),
- label=_('Primary IPv6 (ID)'),
- )
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='oob_ip',
queryset=IPAddress.objects.all(),
@@ -1069,7 +1067,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.exclude(devicebays__isnull=value)
-class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
field_name='device',
queryset=Device.objects.all(),
@@ -1745,6 +1743,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
method='filter_by_cable_end_b',
field_name='terminations__termination_id'
)
+ unterminated = django_filters.BooleanFilter(
+ method='_unterminated',
+ label=_('Unterminated'),
+ )
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@@ -1812,6 +1814,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
# Filter by termination id and cable_end type
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
+ def _unterminated(self, queryset, name, value):
+ if value:
+ terminated_ids = (
+ queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
+ .filter(terminations__cable_end=CableEndChoices.SIDE_B)
+ .values("id")
+ )
+ return queryset.exclude(id__in=terminated_ids)
+ else:
+ return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
+ terminations__cable_end=CableEndChoices.SIDE_B
+ )
+
class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter()
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index cacf1f72b..9c64d8a19 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -420,6 +420,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
widget=BulkEditNullBooleanSelect(),
label=_('Is full depth')
)
+ exclude_from_utilization = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect(),
+ label=_('Exclude from utilization')
+ )
airflow = forms.ChoiceField(
label=_('Airflow'),
choices=add_blank_choice(DeviceAirflowChoices),
@@ -445,7 +450,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
model = DeviceType
fieldsets = (
- (_('Device Type'), ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
+ (_('Device Type'), (
+ 'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
+ 'airflow', 'description',
+ )),
(_('Weight'), ('weight', 'weight_unit')),
)
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index a8e75e3c2..d63873b59 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
)
help_texts = {
'time_zone': mark_safe(
- _('Time zone (available options)')
+ '{} ({})'.format(
+ _('Time zone'), _('available options')
+ )
)
}
@@ -165,7 +167,7 @@ class RackRoleImportForm(NetBoxModelImportForm):
model = RackRole
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00
'),
}
@@ -333,8 +335,8 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
class Meta:
model = DeviceType
fields = [
- 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
- 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
+ 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
+ 'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
]
@@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00
'),
}
@@ -547,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'),
}
- if 'location' in data:
+ if location := data.get('location'):
params.update({
- f"location__{self.fields['location'].to_field_name}": data.get('location'),
+ f"location__{self.fields['location'].to_field_name}": location,
})
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
@@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
queryset=VirtualDeviceContext.objects.all(),
required=False,
to_field_name='name',
- help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
+ help_text=mark_safe(
+ _('VDC names separated by commas, encased with double quotes. Example:') + ' vdc1,vdc2,vdc3
'
+ )
)
type = CSVChoiceField(
label=_('Type'),
@@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00
'),
}
@@ -1096,38 +1100,38 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm):
# Termination A
side_a_device = CSVModelChoiceField(
- label=_('Side a device'),
+ label=_('Side A device'),
queryset=Device.objects.all(),
to_field_name='name',
- help_text=_('Side A device')
+ help_text=_('Device name')
)
side_a_type = CSVContentTypeField(
- label=_('Side a type'),
+ label=_('Side A type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
- help_text=_('Side A type')
+ help_text=_('Termination type')
)
side_a_name = forms.CharField(
- label=_('Side a name'),
- help_text=_('Side A component name')
+ label=_('Side A name'),
+ help_text=_('Termination name')
)
# Termination B
side_b_device = CSVModelChoiceField(
- label=_('Side b device'),
+ label=_('Side B device'),
queryset=Device.objects.all(),
to_field_name='name',
- help_text=_('Side B device')
+ help_text=_('Device name')
)
side_b_type = CSVContentTypeField(
- label=_('Side b type'),
+ label=_('Side B type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
- help_text=_('Side B type')
+ help_text=_('Termination type')
)
side_b_name = forms.CharField(
- label=_('Side b name'),
- help_text=_('Side B component name')
+ label=_('Side B name'),
+ help_text=_('Termination name')
)
# Cable attributes
@@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
]
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00
'),
}
def _clean_side(self, side):
@@ -1188,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
- if termination_object.cable is not None:
+ if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py
index 77543af12..3be4d08e8 100644
--- a/netbox/dcim/forms/common.py
+++ b/netbox/dcim/forms/common.py
@@ -116,17 +116,17 @@ class ModuleCommonForm(forms.Form):
# It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError(
- _("Cannot adopt {name} '{resolved_name}' as it already belongs to a module").format(
- name=template.component_model.__name__,
- resolved_name=resolved_name
+ _("Cannot adopt {model} {name} as it already belongs to a module").format(
+ model=template.component_model.__name__,
+ name=resolved_name
)
)
# If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError(
- _("{name} - {resolved_name} already exists").format(
- name=template.component_model.__name__,
- resolved_name=resolved_name
+ _("A {model} named {name} already exists").format(
+ model=template.component_model.__name__,
+ name=resolved_name
)
)
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 43e5f4481..d0d321187 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Device type')
)
- role_id = DynamicModelMultipleChoiceField(
+ device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Device role')
@@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
- (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
+ (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=add_blank_choice(CableLengthUnitChoices),
required=False
)
+ unterminated = forms.NullBooleanField(
+ label=_('Unterminated'),
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
tag = TagFilterField(model)
@@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(_('PoE'), ('poe_mode', 'poe_type')),
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
vdc_id = DynamicModelMultipleChoiceField(
@@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Cable'), ('cabled', 'occupied')),
)
model = FrontPort
@@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
(_('Cable'), ('cabled', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'position')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
position = forms.CharField(
@@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
@@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
- (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
+ (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 93e214598..da3a2bea4 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm):
fieldsets = (
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
(_('Chassis'), (
- 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+ 'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
+ 'weight', 'weight_unit',
)),
(_('Images'), ('front_image', 'rear_image')),
)
@@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm):
class Meta:
model = DeviceType
fields = [
- 'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth',
- 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
- 'comments', 'tags',
+ 'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
+ 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
+ 'description', 'comments', 'tags',
]
widgets = {
'front_image': ClearableFileInput(attrs={
@@ -442,7 +443,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
platform = DynamicModelChoiceField(
label=_('Platform'),
queryset=Platform.objects.all(),
- required=False
+ required=False,
+ selector=True
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index abd7bd6f6..ea842508f 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
)
self.fields['rear_port'].choices = choices
+ def clean(self):
+
+ # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
+ # positions
+ frontport_count = len(self.cleaned_data['name'])
+ rearport_count = len(self.cleaned_data['rear_port'])
+ if frontport_count != rearport_count:
+ raise forms.ValidationError({
+ 'rear_port': _(
+ "The number of front port templates to be created ({frontport_count}) must match the selected "
+ "number of rear port positions ({rearport_count})."
+ ).format(
+ frontport_count=frontport_count,
+ rearport_count=rearport_count
+ )
+ })
+
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
@@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
)
self.fields['rear_port'].choices = choices
+ def clean(self):
+
+ # Check that the number of FrontPorts to be created matches the selected number of RearPort positions
+ frontport_count = len(self.cleaned_data['name'])
+ rearport_count = len(self.cleaned_data['rear_port'])
+ if frontport_count != rearport_count:
+ raise forms.ValidationError({
+ 'rear_port': _(
+ "The number of front ports to be created ({frontport_count}) must match the selected number of "
+ "rear port positions ({rearport_count})."
+ ).format(
+ frontport_count=frontport_count,
+ rearport_count=rearport_count
+ )
+ })
+
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
diff --git a/netbox/dcim/migrations/0176_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py
index a911d7fd7..60857ecb9 100644
--- a/netbox/dcim/migrations/0176_device_component_counters.py
+++ b/netbox/dcim/migrations/0176_device_component_counters.py
@@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
- devices = Device.objects.annotate(
- _console_port_count=Count('consoleports', distinct=True),
- _console_server_port_count=Count('consoleserverports', distinct=True),
- _power_port_count=Count('powerports', distinct=True),
- _power_outlet_count=Count('poweroutlets', distinct=True),
- _interface_count=Count('interfaces', distinct=True),
- _front_port_count=Count('frontports', distinct=True),
- _rear_port_count=Count('rearports', distinct=True),
- _device_bay_count=Count('devicebays', distinct=True),
- _module_bay_count=Count('modulebays', distinct=True),
- _inventory_item_count=Count('inventoryitems', distinct=True),
- )
- for device in devices:
- device.console_port_count = device._console_port_count
- device.console_server_port_count = device._console_server_port_count
- device.power_port_count = device._power_port_count
- device.power_outlet_count = device._power_outlet_count
- device.interface_count = device._interface_count
- device.front_port_count = device._front_port_count
- device.rear_port_count = device._rear_port_count
- device.device_bay_count = device._device_bay_count
- device.module_bay_count = device._module_bay_count
- device.inventory_item_count = device._inventory_item_count
-
- Device.objects.bulk_update(devices, [
- 'console_port_count',
- 'console_server_port_count',
- 'power_port_count',
- 'power_outlet_count',
- 'interface_count',
- 'front_port_count',
- 'rear_port_count',
- 'device_bay_count',
- 'module_bay_count',
- 'inventory_item_count',
- ], batch_size=100)
+ update_counts(Device, 'console_port_count', 'consoleports')
+ update_counts(Device, 'console_server_port_count', 'consoleserverports')
+ update_counts(Device, 'power_port_count', 'powerports')
+ update_counts(Device, 'power_outlet_count', 'poweroutlets')
+ update_counts(Device, 'interface_count', 'interfaces')
+ update_counts(Device, 'front_port_count', 'frontports')
+ update_counts(Device, 'rear_port_count', 'rearports')
+ update_counts(Device, 'device_bay_count', 'devicebays')
+ update_counts(Device, 'module_bay_count', 'modulebays')
+ update_counts(Device, 'inventory_item_count', 'inventoryitems')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/migrations/0177_devicetype_component_counters.py b/netbox/dcim/migrations/0177_devicetype_component_counters.py
index 66d1460d9..b452ce2d9 100644
--- a/netbox/dcim/migrations/0177_devicetype_component_counters.py
+++ b/netbox/dcim/migrations/0177_devicetype_component_counters.py
@@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def recalculate_devicetype_template_counts(apps, schema_editor):
DeviceType = apps.get_model("dcim", "DeviceType")
- device_types = list(DeviceType.objects.all().annotate(
- _console_port_template_count=Count('consoleporttemplates', distinct=True),
- _console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
- _power_port_template_count=Count('powerporttemplates', distinct=True),
- _power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
- _interface_template_count=Count('interfacetemplates', distinct=True),
- _front_port_template_count=Count('frontporttemplates', distinct=True),
- _rear_port_template_count=Count('rearporttemplates', distinct=True),
- _device_bay_template_count=Count('devicebaytemplates', distinct=True),
- _module_bay_template_count=Count('modulebaytemplates', distinct=True),
- _inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
- ))
- for devicetype in device_types:
- devicetype.console_port_template_count = devicetype._console_port_template_count
- devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
- devicetype.power_port_template_count = devicetype._power_port_template_count
- devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
- devicetype.interface_template_count = devicetype._interface_template_count
- devicetype.front_port_template_count = devicetype._front_port_template_count
- devicetype.rear_port_template_count = devicetype._rear_port_template_count
- devicetype.device_bay_template_count = devicetype._device_bay_template_count
- devicetype.module_bay_template_count = devicetype._module_bay_template_count
- devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
-
- DeviceType.objects.bulk_update(device_types, [
- 'console_port_template_count',
- 'console_server_port_template_count',
- 'power_port_template_count',
- 'power_outlet_template_count',
- 'interface_template_count',
- 'front_port_template_count',
- 'rear_port_template_count',
- 'device_bay_template_count',
- 'module_bay_template_count',
- 'inventory_item_template_count',
- ])
+ update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
+ update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
+ update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
+ update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
+ update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
+ update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
+ update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
+ update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
+ update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
+ update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
index 7d07a4d9d..99b304b66 100644
--- a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
+++ b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
@@ -2,17 +2,13 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
- vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
-
- for vc in vcs:
- vc.member_count = vc._member_count
-
- VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
+ update_counts(VirtualChassis, 'member_count', 'members')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py b/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py
new file mode 100644
index 000000000..6943387c5
--- /dev/null
+++ b/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.5 on 2023-10-20 22:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0181_rename_device_role_device_role'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='devicetype',
+ name='exclude_from_utilization',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0183_protect_child_interfaces.py b/netbox/dcim/migrations/0183_protect_child_interfaces.py
new file mode 100644
index 000000000..ca695f4bd
--- /dev/null
+++ b/netbox/dcim/migrations/0183_protect_child_interfaces.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.6 on 2023-10-20 11:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0182_devicetype_exclude_from_utilization'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='interface',
+ name='parent',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='dcim.interface'),
+ ),
+ ]
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index de7ba0eb6..e276ae3e5 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -2,7 +2,6 @@ import itertools
from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
@@ -10,17 +9,17 @@ from django.dispatch import Signal
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
+from core.models import ContentType
from dcim.choices import *
from dcim.constants import *
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import ChangeLoggedModel, PrimaryModel
-
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from wireless.models import WirelessLink
-from .device_components import FrontPort, RearPort
+from .device_components import FrontPort, RearPort, PathEndpoint
__all__ = (
'Cable',
@@ -98,10 +97,10 @@ class Cable(PrimaryModel):
super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted
- self._pk = self.pk
+ self._pk = self.__dict__.get('id')
# Cache the original status so we can check later if it's been changed
- self._orig_status = self.status
+ self._orig_status = self.__dict__.get('status')
self._terminations_modified = False
@@ -180,6 +179,17 @@ class Cable(PrimaryModel):
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
+ if a_type == b_type:
+ # can't directly use self.a_terminations here as possible they
+ # don't have pk yet
+ a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
+ b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
+
+ if (a_pks & b_pks):
+ raise ValidationError(
+ _("A and B terminations cannot connect to the same object.")
+ )
+
# Run clean() on any new CableTerminations
for termination in self.a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).clean()
@@ -247,7 +257,7 @@ class CableTermination(ChangeLoggedModel):
verbose_name=_('end')
)
termination_type = models.ForeignKey(
- to=ContentType,
+ to='contenttypes.ContentType',
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
@@ -431,6 +441,8 @@ class CablePath(models.Model):
)
_nodes = PathField()
+ _netbox_private = True
+
class Meta:
verbose_name = _('cable path')
verbose_name_plural = _('cable paths')
@@ -518,9 +530,16 @@ class CablePath(models.Model):
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
+ # All mid-span terminations must all be attached to the same device
+ if not isinstance(terminations[0], PathEndpoint):
+ assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
+ assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
+
# Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached)
- if len(set(t.link for t in terminations)) > 1:
+ if len(set(t.link for t in terminations)) > 1 and (
+ position_stack and len(terminations) != len(position_stack[-1])
+ ):
is_split = True
break
@@ -529,46 +548,68 @@ class CablePath(models.Model):
object_to_path_node(t) for t in terminations
])
- # Step 2: Determine the attached link (Cable or WirelessLink), if any
- link = terminations[0].link
- if link is None and len(path) == 1:
- # If this is the start of the path and no link exists, return None
- return None
- elif link is None:
+ # Step 2: Determine the attached links (Cable or WirelessLink), if any
+ links = [termination.link for termination in terminations if termination.link is not None]
+ if len(links) == 0:
+ if len(path) == 1:
+ # If this is the start of the path and no link exists, return None
+ return None
# Otherwise, halt the trace if no link exists
break
- assert type(link) in (Cable, WirelessLink)
+ assert all(type(link) in (Cable, WirelessLink) for link in links)
+ assert all(isinstance(link, type(links[0])) for link in links)
- # Step 3: Record the link and update path status if not "connected"
- path.append([object_to_path_node(link)])
- if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
+ # Step 3: Record asymmetric paths as split
+ not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
+ if len(not_connected_terminations) > 0:
+ is_complete = False
+ is_split = True
+
+ # Step 4: Record the links, keeping cables in order to allow for SVG rendering
+ cables = []
+ for link in links:
+ if object_to_path_node(link) not in cables:
+ cables.append(object_to_path_node(link))
+ path.append(cables)
+
+ # Step 5: Update the path status if a link is not connected
+ links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
+ if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
is_active = False
- # Step 4: Determine the far-end terminations
- if isinstance(link, Cable):
+ # Step 6: Determine the far-end terminations
+ if isinstance(links[0], Cable):
termination_type = ContentType.objects.get_for_model(terminations[0])
local_cable_terminations = CableTermination.objects.filter(
termination_type=termination_type,
termination_id__in=[t.pk for t in terminations]
)
- # Terminations must all belong to same end of Cable
- local_cable_end = local_cable_terminations[0].cable_end
- assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
- remote_cable_terminations = CableTermination.objects.filter(
- cable=link,
- cable_end='A' if local_cable_end == 'B' else 'B'
- )
+
+ q_filter = Q()
+ for lct in local_cable_terminations:
+ cable_end = 'A' if lct.cable_end == 'B' else 'B'
+ q_filter |= Q(cable=lct.cable, cable_end=cable_end)
+
+ remote_cable_terminations = CableTermination.objects.filter(q_filter)
remote_terminations = [ct.termination for ct in remote_cable_terminations]
else:
# WirelessLink
- remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
+ remote_terminations = [
+ link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
+ ]
- # Step 5: Record the far-end termination object(s)
+ # Remote Terminations must all be of the same type, otherwise return a split path
+ if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
+ is_complete = False
+ is_split = True
+ break
+
+ # Step 7: Record the far-end termination object(s)
path.append([
object_to_path_node(t) for t in remote_terminations if t is not None
])
- # Step 6: Determine the "next hop" terminations, if applicable
+ # Step 8: Determine the "next hop" terminations, if applicable
if not remote_terminations:
break
@@ -577,20 +618,32 @@ class CablePath(models.Model):
rear_ports = RearPort.objects.filter(
pk__in=[t.rear_port_id for t in remote_terminations]
)
- if len(rear_ports) > 1:
- assert all(rp.positions == 1 for rp in rear_ports)
- elif rear_ports[0].positions > 1:
+ if len(rear_ports) > 1 or rear_ports[0].positions > 1:
position_stack.append([fp.rear_port_position for fp in remote_terminations])
terminations = rear_ports
elif isinstance(remote_terminations[0], RearPort):
-
- if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
+ if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1
)
+ # Obtain the individual front ports based on the termination and all positions
+ elif len(remote_terminations) > 1 and position_stack:
+ positions = position_stack.pop()
+
+ # Ensure we have a number of positions equal to the amount of remote terminations
+ assert len(remote_terminations) == len(positions)
+
+ # Get our front ports
+ q_filter = Q()
+ for rt in remote_terminations:
+ position = positions.pop()
+ q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
+ assert q_filter is not Q()
+ front_ports = FrontPort.objects.filter(q_filter)
+ # Obtain the individual front ports based on the termination and position
elif position_stack:
front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk,
@@ -632,9 +685,16 @@ class CablePath(models.Model):
terminations = [circuit_termination]
- # Anything else marks the end of the path
else:
- is_complete = True
+ # Check for non-symmetric path
+ if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
+ is_complete = True
+ elif len(remote_terminations) == 0:
+ is_complete = False
+ else:
+ # Unsupported topology, mark as split and exit
+ is_complete = False
+ is_split = True
break
return cls(
@@ -740,3 +800,15 @@ class CablePath(models.Model):
return [
ct.get_peer_termination() for ct in nodes
]
+
+ def get_asymmetric_nodes(self):
+ """
+ Return all available next segments in a split cable path.
+ """
+ from circuits.models import CircuitTermination
+ asymmetric_nodes = []
+ for nodes in self.path_objects:
+ if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
+ asymmetric_nodes.extend([node for node in nodes if node.link is None])
+
+ return asymmetric_nodes
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index f58d2bbca..fb3d6333e 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -1,5 +1,4 @@
from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -89,7 +88,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
super().__init__(*args, **kwargs)
# Cache the original DeviceType ID for reference under clean()
- self._original_device_type = self.device_type_id
+ self._original_device_type = self.__dict__.get('device_type_id')
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
@@ -534,14 +533,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
- _("Rear port ({}) must belong to the same device type").format(self.rear_port)
+ _("Rear port ({name}) must belong to the same device type").format(name=self.rear_port)
)
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
- _("Invalid rear port position ({}); rear port {} has only {} positions").format(
- self.rear_port_position, self.rear_port.name, self.rear_port.positions
+ _("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
+ position=self.rear_port_position,
+ name=self.rear_port.name,
+ count=self.rear_port.positions
)
)
@@ -707,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
db_index=True
)
component_type = models.ForeignKey(
- to=ContentType,
+ to='contenttypes.ContentType',
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
on_delete=models.PROTECT,
related_name='+',
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index e18f25e4f..c24ed4d86 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -1,7 +1,6 @@
from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -86,7 +85,7 @@ class ComponentModel(NetBoxModel):
super().__init__(*args, **kwargs)
# Cache the original Device ID for reference under clean()
- self._original_device = self.device_id
+ self._original_device = self.__dict__.get('device_id')
def __str__(self):
if self.label:
@@ -537,7 +536,7 @@ class BaseInterface(models.Model):
)
parent = models.ForeignKey(
to='self',
- on_delete=models.SET_NULL,
+ on_delete=models.RESTRICT,
related_name='child_interfaces',
null=True,
blank=True,
@@ -799,9 +798,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
if self.bridge and self.bridge.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
- 'bridge': _("""
- The selected bridge interface ({bridge}) belongs to a different device
- ({device}).""").format(bridge=self.bridge, device=self.bridge.device)
+ 'bridge': _(
+ "The selected bridge interface ({bridge}) belongs to a different device ({device})."
+ ).format(bridge=self.bridge, device=self.bridge.device)
})
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({
@@ -889,10 +888,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({
- 'untagged_vlan': _("""
- The untagged VLAN ({untagged_vlan}) must belong to the same site as the
- interface's parent device, or it must be global.
- """).format(untagged_vlan=self.untagged_vlan)
+ 'untagged_vlan': _(
+ "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
+ "device, or it must be global."
+ ).format(untagged_vlan=self.untagged_vlan)
})
def save(self, *args, **kwargs):
@@ -1067,9 +1066,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
frontport_count = self.frontports.count()
if self.positions < frontport_count:
raise ValidationError({
- "positions": _("""
- The number of positions cannot be less than the number of mapped front ports
- ({frontport_count})""").format(frontport_count=frontport_count)
+ "positions": _(
+ "The number of positions cannot be less than the number of mapped front ports "
+ "({frontport_count})"
+ ).format(frontport_count=frontport_count)
})
@@ -1180,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
db_index=True
)
component_type = models.ForeignKey(
- to=ContentType,
+ to='contenttypes.ContentType',
limit_choices_to=MODULAR_COMPONENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 857251caf..07c1c70f6 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -4,6 +4,7 @@ import yaml
from functools import cached_property
from django.core.exceptions import ValidationError
+from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError
@@ -105,6 +106,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
default=1.0,
verbose_name=_('height (U)')
)
+ exclude_from_utilization = models.BooleanField(
+ default=False,
+ verbose_name=_('exclude from utilization'),
+ help_text=_('Exclude from rack utilization calculations.')
+ )
is_full_depth = models.BooleanField(
default=True,
verbose_name=_('is full depth'),
@@ -205,11 +211,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
super().__init__(*args, **kwargs)
# Save a copy of u_height for validation in clean()
- self._original_u_height = self.u_height
+ self._original_u_height = self.__dict__.get('u_height')
# Save references to the original front/rear images
- self._original_front_image = self.front_image
- self._original_rear_image = self.rear_image
+ self._original_front_image = self.__dict__.get('front_image')
+ self._original_rear_image = self.__dict__.get('rear_image')
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
@@ -296,8 +302,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
)
if d.position not in u_available:
raise ValidationError({
- 'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of "
- "{}U").format(d, d.rack, self.u_height)
+ 'u_height': _(
+ "Device {device} in rack {rack} does not have sufficient space to accommodate a "
+ "height of {height}U"
+ ).format(device=d, rack=d.rack, height=self.u_height)
})
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
@@ -332,10 +340,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
ret = super().save(*args, **kwargs)
# Delete any previously uploaded image files that are no longer in use
- if self.front_image != self._original_front_image:
- self._original_front_image.delete(save=False)
- if self.rear_image != self._original_rear_image:
- self._original_rear_image.delete(save=False)
+ if self._original_front_image and self.front_image != self._original_front_image:
+ default_storage.delete(self._original_front_image)
+ if self._original_rear_image and self.rear_image != self._original_rear_image:
+ default_storage.delete(self._original_rear_image)
return ret
@@ -914,7 +922,7 @@ class Device(
if self.primary_ip4:
if self.primary_ip4.family != 4:
raise ValidationError({
- 'primary_ip4': _("{primary_ip4} is not an IPv4 address.").format(primary_ip4=self.primary_ip4)
+ 'primary_ip4': _("{ip} is not an IPv4 address.").format(ip=self.primary_ip4)
})
if self.primary_ip4.assigned_object in vc_interfaces:
pass
@@ -923,13 +931,13 @@ class Device(
else:
raise ValidationError({
'primary_ip4': _(
- "The specified IP address ({primary_ip4}) is not assigned to this device."
- ).format(primary_ip4=self.primary_ip4)
+ "The specified IP address ({ip}) is not assigned to this device."
+ ).format(ip=self.primary_ip4)
})
if self.primary_ip6:
if self.primary_ip6.family != 6:
raise ValidationError({
- 'primary_ip6': _("{primary_ip6} is not an IPv6 address.").format(primary_ip6=self.primary_ip6m)
+ 'primary_ip6': _("{ip} is not an IPv6 address.").format(ip=self.primary_ip6)
})
if self.primary_ip6.assigned_object in vc_interfaces:
pass
@@ -938,8 +946,8 @@ class Device(
else:
raise ValidationError({
'primary_ip6': _(
- "The specified IP address ({primary_ip6}) is not assigned to this device."
- ).format(primary_ip6=self.primary_ip6)
+ "The specified IP address ({ip}) is not assigned to this device."
+ ).format(ip=self.primary_ip6)
})
if self.oob_ip:
if self.oob_ip.assigned_object in vc_interfaces:
@@ -957,17 +965,19 @@ class Device(
raise ValidationError({
'platform': _(
"The assigned platform is limited to {platform_manufacturer} device types, but this device's "
- "type belongs to {device_type_manufacturer}."
+ "type belongs to {devicetype_manufacturer}."
).format(
platform_manufacturer=self.platform.manufacturer,
- device_type_manufacturer=self.device_type.manufacturer
+ devicetype_manufacturer=self.device_type.manufacturer
)
})
# A Device can only be assigned to a Cluster in the same Site (or no Site)
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
raise ValidationError({
- 'cluster': _("The assigned cluster belongs to a different site ({})").format(self.cluster.site)
+ 'cluster': _("The assigned cluster belongs to a different site ({site})").format(
+ site=self.cluster.site
+ )
})
# Validate virtual chassis assignment
@@ -1439,8 +1449,8 @@ class VirtualDeviceContext(PrimaryModel):
if primary_ip.family != family:
raise ValidationError({
f'primary_ip{family}': _(
- "{primary_ip} is not an IPv{family} address."
- ).format(family=family, primary_ip=primary_ip)
+ "{ip} is not an IPv{family} address."
+ ).format(family=family, ip=primary_ip)
})
device_interfaces = self.device.vc_interfaces(if_master=False)
if primary_ip.assigned_object not in device_interfaces:
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
index 95f6d41fe..9be8dc0a3 100644
--- a/netbox/dcim/models/mixins.py
+++ b/netbox/dcim/models/mixins.py
@@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
"""
if self.config_template:
return self.config_template
- if self.role.config_template:
+ if self.role and self.role.config_template:
return self.role.config_template
if self.platform and self.platform.config_template:
return self.platform.config_template
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 83e5eb23a..a852ea5cd 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -174,8 +174,13 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
- raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
- self.rack, self.rack.site, self.power_panel, self.power_panel.site
+ raise ValidationError(_(
+ "Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
+ ).format(
+ rack=self.rack,
+ rack_site=self.rack.site,
+ powerpanel=self.power_panel,
+ powerpanel_site=self.power_panel.site
))
# AC voltage cannot be negative
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index ef0dde4da..0d4b844f9 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -357,7 +357,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
return [u for u in elevation.values()]
- def get_available_units(self, u_height=1, rack_face=None, exclude=None):
+ def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False):
"""
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
@@ -366,9 +366,13 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
:param u_height: Minimum number of contiguous free units required
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
+ :param ignore_excluded_devices: Ignore devices that are marked to exclude from utilization calculations
"""
# Gather all devices which consume U space within the rack
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
+ if ignore_excluded_devices:
+ devices = devices.exclude(device_type__exclude_from_utilization=True)
+
if exclude is not None:
devices = devices.exclude(pk__in=exclude)
@@ -453,7 +457,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
"""
# Determine unoccupied units
total_units = len(list(self.units))
- available_units = self.get_available_units(u_height=0.5)
+ available_units = self.get_available_units(u_height=0.5, ignore_excluded_devices=True)
# Remove reserved units
for ru in self.get_reserved_units():
@@ -558,9 +562,9 @@ class RackReservation(PrimaryModel):
invalid_units = [u for u in self.units if u not in self.rack.units]
if invalid_units:
raise ValidationError({
- 'units': _("Invalid unit(s) for {}U rack: {}").format(
- self.rack.u_height,
- ', '.join([str(u) for u in invalid_units]),
+ 'units': _("Invalid unit(s) for {height}U rack: {unit_list}").format(
+ height=self.rack.u_height,
+ unit_list=', '.join([str(u) for u in invalid_units])
),
})
@@ -571,8 +575,8 @@ class RackReservation(PrimaryModel):
conflicting_units = [u for u in self.units if u in reserved_units]
if conflicting_units:
raise ValidationError({
- 'units': _('The following units have already been reserved: {}').format(
- ', '.join([str(u) for u in conflicting_units]),
+ 'units': _('The following units have already been reserved: {unit_list}').format(
+ unit_list=', '.join([str(u) for u in conflicting_units])
)
})
diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py
index f70c729f4..0784cfaf8 100644
--- a/netbox/dcim/search.py
+++ b/netbox/dcim/search.py
@@ -10,6 +10,7 @@ class CableIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('type', 'status', 'tenant', 'label', 'description')
@register_search
@@ -21,6 +22,7 @@ class ConsolePortIndex(SearchIndex):
('description', 500),
('speed', 2000),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -32,6 +34,7 @@ class ConsoleServerPortIndex(SearchIndex):
('description', 500),
('speed', 2000),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -44,6 +47,9 @@ class DeviceIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = (
+ 'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description',
+ )
@register_search
@@ -54,6 +60,7 @@ class DeviceBayIndex(SearchIndex):
('label', 200),
('description', 500),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -64,6 +71,7 @@ class DeviceRoleIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('description',)
@register_search
@@ -75,6 +83,7 @@ class DeviceTypeIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('manufacturer', 'part_number', 'description')
@register_search
@@ -85,6 +94,7 @@ class FrontPortIndex(SearchIndex):
('label', 200),
('description', 500),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -99,6 +109,7 @@ class InterfaceIndex(SearchIndex):
('mtu', 2000),
('speed', 2000),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -112,6 +123,7 @@ class InventoryItemIndex(SearchIndex):
('description', 500),
('part_id', 2000),
)
+ display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
@register_search
@@ -122,6 +134,7 @@ class LocationIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('site', 'status', 'tenant', 'description')
@register_search
@@ -132,6 +145,7 @@ class ManufacturerIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('description',)
@register_search
@@ -143,6 +157,7 @@ class ModuleIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description')
@register_search
@@ -153,6 +168,7 @@ class ModuleBayIndex(SearchIndex):
('label', 200),
('description', 500),
)
+ display_attrs = ('device', 'label', 'position', 'description')
@register_search
@@ -164,6 +180,7 @@ class ModuleTypeIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('manufacturer', 'model', 'part_number', 'description')
@register_search
@@ -174,6 +191,7 @@ class PlatformIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('manufacturer', 'description')
@register_search
@@ -184,6 +202,7 @@ class PowerFeedIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('power_panel', 'rack', 'status', 'description')
@register_search
@@ -194,6 +213,7 @@ class PowerOutletIndex(SearchIndex):
('label', 200),
('description', 500),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -204,6 +224,7 @@ class PowerPanelIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('site', 'location', 'description')
@register_search
@@ -216,6 +237,7 @@ class PowerPortIndex(SearchIndex):
('maximum_draw', 2000),
('allocated_draw', 2000),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -229,6 +251,7 @@ class RackIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description')
@register_search
@@ -238,6 +261,7 @@ class RackReservationIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('rack', 'tenant', 'user', 'description')
@register_search
@@ -248,6 +272,7 @@ class RackRoleIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('device', 'label', 'description',)
@register_search
@@ -258,6 +283,7 @@ class RearPortIndex(SearchIndex):
('label', 200),
('description', 500),
)
+ display_attrs = ('device', 'label', 'description')
@register_search
@@ -268,6 +294,7 @@ class RegionIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('parent', 'description')
@register_search
@@ -282,6 +309,7 @@ class SiteIndex(SearchIndex):
('shipping_address', 2000),
('comments', 5000),
)
+ display_attrs = ('region', 'group', 'status', 'description')
@register_search
@@ -292,6 +320,7 @@ class SiteGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('parent', 'description')
@register_search
@@ -303,6 +332,7 @@ class VirtualChassisIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('master', 'domain', 'description')
@register_search
@@ -314,3 +344,4 @@ class VirtualDeviceContextIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('device', 'status', 'identifier', 'description')
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index 9413726fa..31e090078 100644
--- a/netbox/dcim/svg/cables.py
+++ b/netbox/dcim/svg/cables.py
@@ -32,11 +32,18 @@ class Node(Hyperlink):
color: Box fill color (RRGGBB format)
labels: An iterable of text strings. Each label will render on a new line within the box.
radius: Box corner radius, for rounded corners (default: 10)
+ object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
+ which terminations.
"""
- def __init__(self, position, width, url, color, labels, radius=10, **extra):
+ object = None
+
+ def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
super(Node, self).__init__(href=url, target='_parent', **extra)
+ # Save object for reference by cable systems
+ self.object = object
+
x, y = position
# Add the box
@@ -77,7 +84,7 @@ class Connector(Group):
labels: Iterable of text labels
"""
- def __init__(self, start, url, color, labels=[], **extra):
+ def __init__(self, start, url, color, labels=[], description=[], **extra):
super().__init__(class_='connector', **extra)
self.start = start
@@ -104,6 +111,8 @@ class Connector(Group):
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
+ if len(description) > 0:
+ link.set_desc("\n".join(description))
self.add(link)
@@ -150,7 +159,10 @@ class CableTraceSVG:
labels.append(location_label)
elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}'
+ labels.append(instance.type)
labels.append(instance.provider)
+ if instance.description:
+ labels.append(instance.description)
elif instance._meta.model_name == 'circuittermination':
if instance.xconnect_id:
labels.append(f'{instance.xconnect_id}')
@@ -170,6 +182,8 @@ class CableTraceSVG:
if hasattr(instance, 'role'):
# Device
return instance.role.color
+ elif instance._meta.model_name == 'circuit' and instance.type.color:
+ return instance.type.color
else:
# Other parent object
return 'e0e0e0'
@@ -206,7 +220,8 @@ class CableTraceSVG:
url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term),
labels=self._get_labels(term),
- radius=5
+ radius=5,
+ object=term
)
nodes_height = max(nodes_height, node.box['height'])
nodes.append(node)
@@ -238,22 +253,65 @@ class CableTraceSVG:
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
- def draw_cable(self, cable):
- labels = [
- f'Cable {cable}',
- cable.get_status_display()
- ]
- if cable.type:
- labels.append(cable.get_type_display())
- if cable.length and cable.length_unit:
- labels.append(f'{cable.length} {cable.get_length_unit_display()}')
+ def draw_cable(self, cable, terminations, cable_count=0):
+ """
+ Draw a single cable. Terminations and cable count are passed for determining position and padding
+
+ :param cable: The cable to draw
+ :param terminations: List of terminations to build positioning data off of
+ :param cable_count: Count of all cables on this layer for determining whether to collapse description into a
+ tooltip.
+ """
+
+ # If the cable count is higher than 2, collapse the description into a tooltip
+ if cable_count > 2:
+ # Use the cable __str__ function to denote the cable
+ labels = [f'{cable}']
+
+ # Include the label and the status description in the tooltip
+ description = [
+ f'Cable {cable}',
+ cable.get_status_display()
+ ]
+
+ if cable.type:
+ # Include the cable type in the tooltip
+ description.append(cable.get_type_display())
+ if cable.length and cable.length_unit:
+ # Include the cable length in the tooltip
+ description.append(f'{cable.length} {cable.get_length_unit_display()}')
+ else:
+ labels = [
+ f'Cable {cable}',
+ cable.get_status_display()
+ ]
+ description = []
+ if cable.type:
+ labels.append(cable.get_type_display())
+ if cable.length and cable.length_unit:
+ # Include the cable length in the tooltip
+ labels.append(f'{cable.length} {cable.get_length_unit_display()}')
+
+ # If there is only one termination, center on that termination
+ # Otherwise average the center across the terminations
+ if len(terminations) == 1:
+ center = terminations[0].bottom_center[0]
+ else:
+ # Get a list of termination centers
+ termination_centers = [term.bottom_center[0] for term in terminations]
+ # Average the centers
+ center = sum(termination_centers) / len(termination_centers)
+
+ # Create the connector
connector = Connector(
- start=(self.center + OFFSET, self.cursor),
+ start=(center, self.cursor),
color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}',
- labels=labels
+ labels=labels,
+ description=description
)
+ # Set the cursor position
self.cursor += connector.height
return connector
@@ -334,34 +392,52 @@ class CableTraceSVG:
# Connector (a Cable or WirelessLink)
if links:
- link = links[0] # Remove Cable from list
+ link_cables = {}
+ fanin = False
+ fanout = False
- # Cable
- if type(link) is Cable:
+ # Determine if we have fanins or fanouts
+ if len(near_ends) > len(set(links)):
+ self.cursor += FANOUT_HEIGHT
+ fanin = True
+ if len(far_ends) > len(set(links)):
+ fanout = True
+ cursor = self.cursor
+ for link in links:
+ # Cable
+ if type(link) is Cable and not link_cables.get(link.pk):
+ # Reset cursor
+ self.cursor = cursor
+ # Generate a list of terminations connected to this cable
+ near_end_link_terminations = [term for term in terminations if term.object.cable == link]
+ # Draw the cable
+ cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
+ # Add cable to the list of cables
+ link_cables.update({link.pk: cable})
+ # Add cable to drawing
+ self.connectors.append(cable)
- # Account for fan-ins height
- if len(near_ends) > 1:
- self.cursor += FANOUT_HEIGHT
+ # Draw fan-ins
+ if len(near_ends) > 1 and fanin:
+ for term in terminations:
+ if term.object.cable == link:
+ self.draw_fanin(term, cable)
- cable = self.draw_cable(link)
- self.connectors.append(cable)
-
- # Draw fan-ins
- if len(near_ends) > 1:
- for term in terminations:
- self.draw_fanin(term, cable)
-
- # WirelessLink
- elif type(link) is WirelessLink:
- wirelesslink = self.draw_wirelesslink(link)
- self.connectors.append(wirelesslink)
+ # WirelessLink
+ elif type(link) is WirelessLink:
+ wirelesslink = self.draw_wirelesslink(link)
+ self.connectors.append(wirelesslink)
# Far end termination(s)
if len(far_ends) > 1:
- self.cursor += FANOUT_HEIGHT
- terminations = self.draw_terminations(far_ends)
- for term in terminations:
- self.draw_fanout(term, cable)
+ if fanout:
+ self.cursor += FANOUT_HEIGHT
+ terminations = self.draw_terminations(far_ends)
+ for term in terminations:
+ if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
+ self.draw_fanout(term, link_cables.get(term.object.cable.pk))
+ else:
+ self.draw_terminations(far_ends)
elif far_ends:
self.draw_terminations(far_ends)
else:
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 68c24ca14..b72c37daa 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
Get interface enabled state as string to attach to 00ff00
)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00
'),
}
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 7db84d175..5da3ba1e6 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -1,13 +1,11 @@
from django import forms
from django.contrib.auth import get_user_model
-from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
-from core.models import DataFile, DataSource
+from core.models import ContentType, DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
-from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
@@ -40,12 +38,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
(_('Attributes'), (
- 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
+ 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable',
'is_cloneable',
)),
)
content_type_id = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
+ queryset=ContentType.objects.with_feature('custom_fields'),
required=False,
label=_('Object type')
)
@@ -74,10 +72,15 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Choice set')
)
- ui_visibility = forms.ChoiceField(
- choices=add_blank_choice(CustomFieldVisibilityChoices),
+ ui_visible = forms.ChoiceField(
+ choices=add_blank_choice(CustomFieldUIVisibleChoices),
required=False,
- label=_('UI visibility')
+ label=_('UI visible')
+ )
+ ui_editable = forms.ChoiceField(
+ choices=add_blank_choice(CustomFieldUIEditableChoices),
+ required=False,
+ label=_('UI editable')
)
is_cloneable = forms.NullBooleanField(
label=_('Is cloneable'),
@@ -109,7 +112,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
)
content_types = ContentTypeMultipleChoiceField(
label=_('Content types'),
- queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
+ queryset=ContentType.objects.with_feature('custom_links'),
required=False
)
enabled = forms.NullBooleanField(
@@ -152,7 +155,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
}
)
content_type_id = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
+ queryset=ContentType.objects.with_feature('export_templates'),
required=False,
label=_('Content types')
)
@@ -180,7 +183,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
)
content_type_id = ContentTypeChoiceField(
label=_('Content type'),
- queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()),
+ queryset=ContentType.objects.with_feature('image_attachments'),
required=False
)
name = forms.CharField(
@@ -196,7 +199,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
)
content_types = ContentTypeMultipleChoiceField(
label=_('Content types'),
- queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
+ queryset=ContentType.objects.public(),
required=False
)
enabled = forms.NullBooleanField(
@@ -229,7 +232,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
)
content_type_id = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
+ queryset=ContentType.objects.with_feature('webhooks'),
required=False,
label=_('Object type')
)
@@ -285,12 +288,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
class TagFilterForm(SavedFiltersMixin, FilterForm):
model = Tag
content_type_id = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+ queryset=ContentType.objects.with_feature('tags'),
required=False,
label=_('Tagged object type')
)
for_object_type_id = ContentTypeChoiceField(
- queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+ queryset=ContentType.objects.with_feature('tags'),
required=False,
label=_('Allowed object type')
)
diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py
index be45f5211..e9fb897c0 100644
--- a/netbox/extras/forms/mixins.py
+++ b/netbox/extras/forms/mixins.py
@@ -2,13 +2,14 @@ from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
-from extras.choices import CustomFieldVisibilityChoices
+from extras.choices import *
from extras.models import *
from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = (
'CustomFieldsMixin',
'SavedFiltersMixin',
+ 'TagsMixin',
)
@@ -39,7 +40,7 @@ class CustomFieldsMixin:
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude(
- ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
+ ui_visible=CustomFieldUIVisibleChoices.HIDDEN
)
def _get_form_field(self, customfield):
@@ -50,9 +51,6 @@ class CustomFieldsMixin:
Append form fields for all CustomFields assigned to this object type.
"""
for customfield in self._get_custom_fields(self._get_content_type()):
- if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
- continue
-
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
@@ -72,3 +70,19 @@ class SavedFiltersMixin(forms.Form):
'usable': True,
}
)
+
+
+class TagsMixin(forms.Form):
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False,
+ label=_('Tags'),
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Limit tags to those applicable to the object type
+ content_type = ContentType.objects.get_for_model(self._meta.model)
+ if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
+ self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk)
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index d4e59c170..1a4d45f9a 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -2,15 +2,14 @@ import json
from django import forms
from django.conf import settings
-from django.db.models import Q
-from django.contrib.contenttypes.models import ContentType
+from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin
+from core.models import ContentType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
-from extras.utils import FeatureQuery
from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
@@ -43,14 +42,11 @@ __all__ = (
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
label=_('Content types'),
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
+ queryset=ContentType.objects.with_feature('custom_fields')
)
object_type = ContentTypeChoiceField(
label=_('Object type'),
- queryset=ContentType.objects.all(),
- # TODO: Come up with a canonical way to register suitable models
- limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
+ queryset=ContentType.objects.public(),
required=False,
help_text=_("Type of the related object (for object/multi-object fields only)")
)
@@ -63,7 +59,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
(_('Custom Field'), (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
)),
- (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
+ (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')),
(_('Values'), ('default', 'choice_set')),
(_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
@@ -75,13 +71,15 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
'type': _(
"The type of data stored in this field. For object/multi-object fields, select the related object "
"type below."
- )
+ ),
+ 'description': _("This will be displayed as help text for the form field. Markdown is supported.")
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- # Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
+ # Disable changing the type of a CustomField as it almost universally causes errors if custom field data
+ # is already present.
if self.instance.pk:
self.fields['type'].disabled = True
@@ -90,10 +88,10 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ChoicesWidget(),
required=False,
- help_text=_(
+ help_text=mark_safe(_(
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
- 'comma (for example, "choice1,First Choice").'
- )
+ 'comma. Example:'
+ ) + ' choice1,First Choice
')
)
class Meta:
@@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
label=_('Content types'),
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_links')
+ queryset=ContentType.objects.with_feature('custom_links')
)
fieldsets = (
@@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
label=_('Content types'),
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('export_templates')
+ queryset=ContentType.objects.with_feature('export_templates')
)
template_code = forms.CharField(
label=_('Template code'),
@@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
class BookmarkForm(BootstrapMixin, forms.ModelForm):
object_type = ContentTypeChoiceField(
label=_('Object type'),
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('bookmarks').get_query()
+ queryset=ContentType.objects.with_feature('bookmarks')
)
class Meta:
@@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm):
class WebhookForm(NetBoxModelForm):
content_types = ContentTypeMultipleChoiceField(
label=_('Content types'),
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('webhooks')
+ queryset=ContentType.objects.with_feature('webhooks')
)
fieldsets = (
@@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
object_types = ContentTypeMultipleChoiceField(
label=_('Object types'),
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('tags'),
+ queryset=ContentType.objects.with_feature('tags'),
required=False
)
@@ -325,7 +318,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
required=False
)
tenant_groups = DynamicModelMultipleChoiceField(
- label=_('Tenat groups'),
+ label=_('Tenant groups'),
queryset=TenantGroup.objects.all(),
required=False
)
@@ -488,7 +481,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
- (_('Validation'), ('CUSTOM_VALIDATORS',)),
+ (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
(_('Miscellaneous'), (
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
@@ -505,6 +498,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
+ 'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
'comment': forms.Textarea(),
}
@@ -515,22 +509,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
config = get_config()
for param in PARAMS:
value = getattr(config, param.name)
- is_static = hasattr(settings, param.name)
- if value:
- help_text = self.fields[param.name].help_text
- if help_text:
- help_text += '{stacktrace}") logger.error(f"Exception raised during report execution: {e}") - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) # Perform any post-run tasks self.post_run() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index e93326ddc..df75200e6 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -519,7 +519,7 @@ def run_script(data, request, job, commit=True, **kwargs): logger.error(f"Exception raised during script execution: {e}") script.log_info("Database changes have been reverted due to error.") job.data = ScriptOutputSerializer(script).data - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) clear_webhooks.send(request) logger.info(f"Script completed in {job.duration}") diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index d6550309f..8bdaf523c 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -2,8 +2,10 @@ import importlib import logging from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal +from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates from extras.validators import CustomValidator @@ -178,11 +180,7 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type # Custom validation # -@receiver(post_clean) -def run_custom_validators(sender, instance, **kwargs): - config = get_config() - model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' - validators = config.CUSTOM_VALIDATORS.get(model_name, []) +def run_validators(instance, validators): for validator in validators: @@ -198,6 +196,29 @@ def run_custom_validators(sender, instance, **kwargs): validator(instance) +@receiver(post_clean) +def run_save_validators(sender, instance, **kwargs): + model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' + validators = get_config().CUSTOM_VALIDATORS.get(model_name, []) + + run_validators(instance, validators) + + +@receiver(pre_delete) +def run_delete_validators(sender, instance, **kwargs): + model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' + validators = get_config().PROTECTION_RULES.get(model_name, []) + + try: + run_validators(instance, validators) + except ValidationError as e: + raise AbortRequest( + _("Deletion is prevented by a protection rule: {message}").format( + message=e + ) + ) + + # # Dynamic configuration # diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 9e14a2d27..54194c00f 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -71,8 +71,11 @@ class CustomFieldTable(NetBoxTable): required = columns.BooleanColumn( verbose_name=_('Required') ) - ui_visibility = columns.ChoiceFieldColumn( - verbose_name=_('UI Visibility') + ui_visible = columns.ChoiceFieldColumn( + verbose_name=_('Visible') + ) + ui_editable = columns.ChoiceFieldColumn( + verbose_name=_('Editable') ) description = columns.MarkdownColumn( verbose_name=_('Description') @@ -94,8 +97,8 @@ class CustomFieldTable(NetBoxTable): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', - 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices', - 'created', 'last_updated', + 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set', + 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 019aef235..7ac6b2035 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -12,6 +12,7 @@ from dcim.models import Manufacturer, Rack, Site from extras.choices import * from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -427,6 +428,97 @@ class CustomFieldTest(TestCase): self.assertNotIn('field1', site.custom_field_data) self.assertEqual(site.custom_field_data['field2'], FIELD_DATA) + def test_default_value_validation(self): + choiceset = CustomFieldChoiceSet.objects.create( + name="Test Choice Set", + extra_choices=( + ('choice1', 'Choice 1'), + ('choice2', 'Choice 2'), + ) + ) + site = Site.objects.create(name='Site 1', slug='site-1') + object_type = ContentType.objects.get_for_model(Site) + + # Text + CustomField(name='test', type='text', required=True, default="Default text").full_clean() + + # Integer + CustomField(name='test', type='integer', required=True, default=1).full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='integer', required=True, default='xxx').full_clean() + + # Boolean + CustomField(name='test', type='boolean', required=True, default=True).full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='boolean', required=True, default='xxx').full_clean() + + # Date + CustomField(name='test', type='date', required=True, default="2023-02-25").full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='date', required=True, default='xxx').full_clean() + + # Datetime + CustomField(name='test', type='datetime', required=True, default="2023-02-25 02:02:02").full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='datetime', required=True, default='xxx').full_clean() + + # URL + CustomField(name='test', type='url', required=True, default="https://www.netbox.dev").full_clean() + + # JSON + CustomField(name='test', type='json', required=True, default='{"test": "object"}').full_clean() + + # Selection + CustomField(name='test', type='select', required=True, choice_set=choiceset, default='choice1').full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='select', required=True, choice_set=choiceset, default='xxx').full_clean() + + # Multi-select + CustomField( + name='test', + type='multiselect', + required=True, + choice_set=choiceset, + default=['choice1'] # Single default choice + ).full_clean() + CustomField( + name='test', + type='multiselect', + required=True, + choice_set=choiceset, + default=['choice1', 'choice2'] # Multiple default choices + ).full_clean() + with self.assertRaises(ValidationError): + CustomField( + name='test', + type='multiselect', + required=True, + choice_set=choiceset, + default=['xxx'] + ).full_clean() + + # Object + CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean() + + # Multi-object + CustomField( + name='test', + type='multiobject', + required=True, + object_type=object_type, + default=[site.pk] + ).full_clean() + with self.assertRaises(ValidationError): + CustomField( + name='test', + type='multiobject', + required=True, + object_type=object_type, + default=["xxx"] + ).full_clean() + class CustomFieldManagerTest(TestCase): @@ -1085,7 +1177,11 @@ class CustomFieldImportTest(TestCase): ) csv_data = '\n'.join(','.join(row) for row in data) - response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'}) + response = self.client.post(reverse('dcim:site_import'), { + 'data': csv_data, + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, + }) self.assertEqual(response.status_code, 302) self.assertEqual(Site.objects.count(), 3) diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidation.py similarity index 64% rename from netbox/extras/tests/test_customvalidator.py rename to netbox/extras/tests/test_customvalidation.py index 0fe507b67..d74ad599b 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidation.py @@ -1,10 +1,13 @@ from django.conf import settings from django.core.exceptions import ValidationError +from django.db import transaction from django.test import TestCase, override_settings from ipam.models import ASN, RIR +from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.validators import CustomValidator +from utilities.exceptions import AbortRequest class MyValidator(CustomValidator): @@ -14,6 +17,20 @@ class MyValidator(CustomValidator): self.fail("Name must be foo!") +eq_validator = CustomValidator({ + 'asn': { + 'eq': 100 + } +}) + + +neq_validator = CustomValidator({ + 'asn': { + 'neq': 100 + } +}) + + min_validator = CustomValidator({ 'asn': { 'min': 65000 @@ -77,6 +94,18 @@ class CustomValidatorTest(TestCase): validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0] self.assertIsInstance(validator, CustomValidator) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [eq_validator]}) + def test_eq(self): + ASN(asn=100, rir=RIR.objects.first()).clean() + with self.assertRaises(ValidationError): + ASN(asn=99, rir=RIR.objects.first()).clean() + + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [neq_validator]}) + def test_neq(self): + ASN(asn=99, rir=RIR.objects.first()).clean() + with self.assertRaises(ValidationError): + ASN(asn=100, rir=RIR.objects.first()).clean() + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_min(self): with self.assertRaises(ValidationError): @@ -147,7 +176,7 @@ class CustomValidatorConfigTest(TestCase): @override_settings( CUSTOM_VALIDATORS={ 'dcim.site': ( - 'extras.tests.test_customvalidator.MyValidator', + 'extras.tests.test_customvalidation.MyValidator', ) } ) @@ -159,3 +188,62 @@ class CustomValidatorConfigTest(TestCase): Site(name='foo', slug='foo').clean() with self.assertRaises(ValidationError): Site(name='bar', slug='bar').clean() + + +class ProtectionRulesConfigTest(TestCase): + + @override_settings( + PROTECTION_RULES={ + 'dcim.site': [ + {'status': {'eq': SiteStatusChoices.STATUS_DECOMMISSIONING}} + ] + } + ) + def test_plain_data(self): + """ + Test custom validator configuration using plain data (as opposed to a CustomValidator + class) + """ + # Create a site with a protected status + site = Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE) + site.save() + + # Try to delete it + with self.assertRaises(AbortRequest): + with transaction.atomic(): + site.delete() + + # Change its status to an allowed value + site.status = SiteStatusChoices.STATUS_DECOMMISSIONING + site.save() + + # Deletion should now succeed + site.delete() + + @override_settings( + PROTECTION_RULES={ + 'dcim.site': ( + 'extras.tests.test_customvalidation.MyValidator', + ) + } + ) + def test_dotted_path(self): + """ + Test custom validator configuration using a dotted path (string) reference to a + CustomValidator class. + """ + # Create a site with a protected name + site = Site(name='bar', slug='bar') + site.save() + + # Try to delete it + with self.assertRaises(AbortRequest): + with transaction.atomic(): + site.delete() + + # Change the name to an allowed value + site.name = site.slug = 'foo' + site.save() + + # Deletion should now succeed + site.delete() diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 69111e6a7..c5a6706c0 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -40,7 +40,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=True, weight=100, filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ui_visible=CustomFieldUIVisibleChoices.ALWAYS, + ui_editable=CustomFieldUIEditableChoices.YES ), CustomField( name='Custom Field 2', @@ -48,7 +49,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=200, filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY + ui_visible=CustomFieldUIVisibleChoices.IF_SET, + ui_editable=CustomFieldUIEditableChoices.NO ), CustomField( name='Custom Field 3', @@ -56,7 +58,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=300, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN ), CustomField( name='Custom Field 4', @@ -64,7 +67,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=400, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[0] ), CustomField( @@ -73,7 +77,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=500, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[1] ), ) @@ -106,8 +111,12 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_ui_visibility(self): - params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} + def test_ui_visible(self): + params = {'ui_visible': CustomFieldUIVisibleChoices.ALWAYS} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_ui_editable(self): + params = {'ui_editable': CustomFieldUIEditableChoices.YES} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_choice_set(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 01ef9a2a6..3d4b3e9a9 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from dcim.models import Site +from dcim.models import DeviceType, Manufacturer, Site from extras.choices import * from extras.models import * from utilities.testing import ViewTestCases, TestCase @@ -50,15 +50,16 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'default': None, 'weight': 200, 'required': True, - 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + 'ui_visible': CustomFieldUIVisibleChoices.ALWAYS, + 'ui_editable': CustomFieldUIEditableChoices.YES, } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility', - 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write', - 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write', - 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write', - 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', + 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable', + 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes', + 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes', + 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes', + 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,always,yes', ) cls.csv_update_data = ( @@ -434,7 +435,8 @@ class ConfigContextTestCase( @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') # Create three ConfigContexts for i in range(1, 4): @@ -443,7 +445,7 @@ class ConfigContextTestCase( data={'foo': i} ) configcontext.save() - configcontext.sites.add(site) + configcontext.device_types.add(devicetype) cls.form_data = { 'name': 'Config Context X', @@ -451,11 +453,12 @@ class ConfigContextTestCase( 'description': 'A new config context', 'is_active': True, 'regions': [], - 'sites': [site.pk], + 'sites': [], 'roles': [], 'platforms': [], 'tenant_groups': [], 'tenants': [], + 'device_types': [devicetype.id], 'tags': [], 'data': '{"foo": 123}', } diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 23892e098..c6b2de188 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -from django.db.models import Q -from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from netbox.registry import registry @@ -31,29 +29,6 @@ def image_upload(instance, filename): return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) -@deconstructible -class FeatureQuery: - """ - Helper class that delays evaluation of the registry contents for the functionality store - until it has been populated. - """ - def __init__(self, feature): - self.feature = feature - - def __call__(self): - return self.get_query() - - def get_query(self): - """ - Given an extras feature, return a Q object for content type lookup - """ - query = Q() - for app_label, models in registry['model_features'][self.feature].items(): - query |= Q(app_label=app_label, model__in=models) - - return query - - def register_features(model, features): """ Register model features in the application registry. @@ -67,6 +42,10 @@ def register_features(model, features): f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}" ) + # Register public models + if not getattr(model, '_netbox_private', False): + registry['models'][app_label].add(model_name) + def is_script(obj): """ diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index 686c9b032..98b4fd88d 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,15 +1,38 @@ -from django.core.exceptions import ValidationError from django.core import validators +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ # NOTE: As this module may be imported by configuration.py, we cannot import # anything from NetBox itself. +class IsEqualValidator(validators.BaseValidator): + """ + Employed by CustomValidator to require a specific value. + """ + message = _("Ensure this value is equal to %(limit_value)s.") + code = "is_equal" + + def compare(self, a, b): + return a != b + + +class IsNotEqualValidator(validators.BaseValidator): + """ + Employed by CustomValidator to exclude a specific value. + """ + message = _("Ensure this value does not equal %(limit_value)s.") + code = "is_not_equal" + + def compare(self, a, b): + return a == b + + class IsEmptyValidator: """ Employed by CustomValidator to enforce required fields. """ - message = "This field must be empty." + message = _("This field must be empty.") code = 'is_empty' def __init__(self, enforce=True): @@ -24,7 +47,7 @@ class IsNotEmptyValidator: """ Employed by CustomValidator to enforce prohibited fields. """ - message = "This field must not be empty." + message = _("This field must not be empty.") code = 'not_empty' def __init__(self, enforce=True): @@ -50,6 +73,8 @@ class CustomValidator: :param validation_rules: A dictionary mapping object attributes to validation rules """ VALIDATORS = { + 'eq': IsEqualValidator, + 'neq': IsNotEqualValidator, 'min': validators.MinValueValidator, 'max': validators.MaxValueValidator, 'min_length': validators.MinLengthValidator, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9efcc02dc..0e8e3b0ea 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -16,6 +16,7 @@ from core.tables import JobTable from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class from netbox.config import get_config, PARAMS +from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import is_htmx @@ -210,7 +211,10 @@ class ExportTemplateListView(generic.ObjectListView): filterset_form = forms.ExportTemplateFilterForm table = tables.ExportTemplateTable 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) @@ -472,7 +476,12 @@ class ConfigContextListView(generic.ObjectListView): filterset_form = forms.ConfigContextFilterForm table = tables.ConfigContextTable 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) @@ -576,7 +585,10 @@ class ConfigTemplateListView(generic.ObjectListView): filterset_form = forms.ConfigTemplateFilterForm table = tables.ConfigTemplateTable 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) @@ -627,7 +639,9 @@ class ObjectChangeListView(generic.ObjectListView): filterset_form = forms.ObjectChangeFilterForm table = tables.ObjectChangeTable template_name = 'extras/objectchange_list.html' - actions = ('export',) + actions = { + 'export': {'view'}, + } @register_model_view(ObjectChange) @@ -693,7 +707,9 @@ class ImageAttachmentListView(generic.ObjectListView): filterset = filtersets.ImageAttachmentFilterSet filterset_form = forms.ImageAttachmentFilterForm table = tables.ImageAttachmentTable - actions = ('export',) + actions = { + 'export': {'view'}, + } @register_model_view(ImageAttachment, 'edit') @@ -736,7 +752,12 @@ class JournalEntryListView(generic.ObjectListView): filterset = filtersets.JournalEntryFilterSet filterset_form = forms.JournalEntryFilterForm 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) @@ -978,6 +999,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): }) +def get_report_module(module, request): + return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ReportView(ContentTypePermissionRequiredMixin, View): """ Display a single Report and its associated Job (if any). @@ -986,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1007,7 +1032,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_report'): return HttpResponseForbidden() - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled) @@ -1046,7 +1071,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() return render(request, 'extras/report/source.html', { @@ -1062,7 +1087,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1151,13 +1176,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): }) +def get_script_module(module, request): + return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ScriptView(ContentTypePermissionRequiredMixin, View): def get_required_permission(self): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(initial=normalize_querydict(request.GET)) @@ -1181,7 +1210,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_script'): return HttpResponseForbidden() - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(request.POST, request.FILES) @@ -1218,7 +1247,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() return render(request, 'extras/script/source.html', { @@ -1234,7 +1263,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() object_type = ContentType.objects.get(app_label='extras', model='scriptmodule') diff --git a/netbox/ipam/api/field_serializers.py b/netbox/ipam/api/field_serializers.py index d44d8b7d4..d12530a60 100644 --- a/netbox/ipam/api/field_serializers.py +++ b/netbox/ipam/api/field_serializers.py @@ -1,21 +1,18 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from ipam import models from netaddr import AddrFormatError, IPNetwork -__all__ = [ +__all__ = ( 'IPAddressField', -] + 'IPNetworkField', +) -# -# IP address field -# - class IPAddressField(serializers.CharField): - """IPAddressField with mask""" - + """ + An IPv4 or IPv6 address with optional mask + """ default_error_messages = { 'invalid': _('Enter a valid IPv4 or IPv6 address with optional mask.'), } @@ -24,7 +21,27 @@ class IPAddressField(serializers.CharField): try: return IPNetwork(data) except AddrFormatError: - raise serializers.ValidationError("Invalid IP address format: {}".format(data)) + raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data)) + except (TypeError, ValueError) as e: + raise serializers.ValidationError(e) + + def to_representation(self, value): + return str(value) + + +class IPNetworkField(serializers.CharField): + """ + An IPv4 or IPv6 prefix + """ + default_error_messages = { + 'invalid': _('Enter a valid IPv4 or IPv6 prefix and mask in CIDR notation.'), + } + + def to_internal_value(self, data): + try: + return IPNetwork(data) + except AddrFormatError: + raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data)) except (TypeError, ValueError) as e: raise serializers.ValidationError(e) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 93a5d36eb..c46949bb1 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -13,7 +13,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * -from .field_serializers import IPAddressField +from .field_serializers import IPAddressField, IPNetworkField # @@ -138,7 +138,7 @@ class AggregateSerializer(NetBoxModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) rir = NestedRIRSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) - prefix = serializers.CharField() + prefix = IPNetworkField() class Meta: model = Aggregate @@ -146,7 +146,6 @@ class AggregateSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family'] # @@ -306,7 +305,7 @@ class PrefixSerializer(NetBoxModelSerializer): role = NestedRoleSerializer(required=False, allow_null=True) children = serializers.IntegerField(read_only=True) _depth = serializers.IntegerField(read_only=True) - prefix = serializers.CharField() + prefix = IPNetworkField() class Meta: model = Prefix @@ -315,7 +314,6 @@ class PrefixSerializer(NetBoxModelSerializer): 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', '_depth', ] - read_only_fields = ['family'] class PrefixLengthSerializer(serializers.Serializer): @@ -386,7 +384,6 @@ class IPRangeSerializer(NetBoxModelSerializer): 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family'] # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index da6463e23..662b393de 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): # Normalize request data to a list of objects requested_objects = request.data if isinstance(request.data, list) else [request.data] + limit = len(requested_objects) # Serialize and validate the request data serializer = self.write_serializer_class(data=requested_objects, many=True, context={ @@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): - available_objects = self.get_available_objects(parent) + available_objects = self.get_available_objects(parent, limit) # Determine if the requested number of objects is available if not self.check_sufficient_available(serializer.validated_data, available_objects): @@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) # Prepare object data for deserialization - requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) + requested_objects = self.prep_object_data(requested_objects, available_objects, parent) # Initialize the serializer with a list or a single object depending on what was requested serializer_class = get_serializer_for_model(self.queryset.model) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index c461de3bd..c296774b9 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -29,6 +29,7 @@ __all__ = ( 'L2VPNFilterSet', 'L2VPNTerminationFilterSet', 'PrefixFilterSet', + 'PrimaryIPFilterSet', 'RIRFilterSet', 'RoleFilterSet', 'RouteTargetFilterSet', @@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) mask_length = MultiValueNumberFilter( field_name='prefix', - lookup_expr='net_mask_length' + lookup_expr='net_mask_length', + label=_('Mask length') ) mask_length__gte = django_filters.NumberFilter( field_name='prefix', @@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): method='filter_address', label=_('Address'), ) - mask_length = django_filters.NumberFilter( - method='filter_mask_length', - label=_('Mask length'), + mask_length = MultiValueNumberFilter( + field_name='address', + lookup_expr='net_mask_length', + label=_('Mask length') + ) + mask_length__gte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__gte' + ) + mask_length__lte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__lte' ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), @@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except ValidationError: return queryset.none() - def filter_mask_length(self, queryset, name, value): - if not value: - return queryset - return queryset.filter(address__net_mask_length=value) - @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: @@ -1236,3 +1242,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): ) return qs + + +class PrimaryIPFilterSet(django_filters.FilterSet): + """ + An inheritable FilterSet for models which support primary IP assignment. + """ + primary_ip4_id = django_filters.ModelMultipleChoiceFilter( + field_name='primary_ip4', + queryset=IPAddress.objects.all(), + label=_('Primary IPv4 (ID)'), + ) + primary_ip6_id = django_filters.ModelMultipleChoiceFilter( + field_name='primary_ip6', + queryset=IPAddress.objects.all(), + label=_('Primary IPv6 (ID)'), + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 548d01afa..f0a8286fc 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,7 +1,8 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import ( - CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, + CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, ) from utilities.forms.widgets import BulkEditNullBooleanSelect +from virtualization.models import Cluster, ClusterGroup __all__ = ( 'AggregateBulkEditForm', @@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False - ) min_vid = forms.IntegerField( min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, @@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + scope_type = ContentTypeChoiceField( + label=_('Scope type'), + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False + ) + scope_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput() + ) + region = DynamicModelChoiceField( + label=_('Region'), + queryset=Region.objects.all(), + required=False + ) + sitegroup = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site = DynamicModelChoiceField( + label=_('Site'), + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$sitegroup', + } + ) + location = DynamicModelChoiceField( + label=_('Location'), + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + } + ) + rack = DynamicModelChoiceField( + label=_('Rack'), + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + clustergroup = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + label=_('Cluster group') + ) + cluster = DynamicModelChoiceField( + label=_('Cluster'), + queryset=Cluster.objects.all(), + required=False, + query_params={ + 'group_id': '$clustergroup', + } + ) model = VLANGroup fieldsets = ( (None, ('site', 'min_vid', 'max_vid', 'description')), + (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) - nullable_fields = ('site', 'description') + nullable_fields = ('description',) + + def clean(self): + super().clean() + + # Assign scope based on scope_type + if self.cleaned_data.get('scope_type'): + scope_field = self.cleaned_data['scope_type'].model + if scope_obj := self.cleaned_data.get(scope_field): + self.cleaned_data['scope_id'] = scope_obj.pk + self.changed_data.append('scope_id') + else: + self.cleaned_data.pop('scope_type') + self.changed_data.remove('scope_type') class VLANBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index ac3c99468..ed3ceec2b 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm): choices=ServiceProtocolChoices, help_text=_('IP protocol') ) + ipaddresses = CSVModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + required=False, + to_field_name='address', + help_text=_('IP Address'), + ) class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') + fields = ( + 'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', + ) + + def clean_ipaddresses(self): + parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') + for ip_address in self.cleaned_data['ipaddresses']: + if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent: + raise forms.ValidationError( + _("{ip} is not assigned to this device/VM.").format(ip=ip_address) + ) + + return self.cleaned_data['ipaddresses'] class L2VPNImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index e4e967f81..a8ca91901 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), + (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')), (_('VRF'), ('vrf_id', 'present_in_vrf_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Device/VM'), ('device_id', 'virtual_machine_id')), @@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + dns_name = forms.CharField( + required=False, + label=_('DNS Name') + ) tag = TagFilterField(model) @@ -519,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Attributes'), ('protocol', 'port')), + (_('Assignment'), ('device_id', 'virtual_machine_id')), + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device'), + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label=_('Virtual Machine'), + ) tag = TagFilterField(model) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 2e0c4bd30..a05145d6e 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -215,6 +215,9 @@ class PrefixForm(TenancyForm, NetBoxModelForm): queryset=VLAN.objects.all(), required=False, selector=True, + query_params={ + 'site_id': '$site', + }, label=_('VLAN'), ) role = DynamicModelChoiceField( @@ -351,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): }) elif selected_objects: assigned_object = self.cleaned_data[selected_objects[0]] - if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: raise ValidationError( _("Cannot reassign IP address while it is designated as the primary IP for the parent object") ) @@ -369,14 +372,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): # Do not allow assigning a network ID or broadcast address to an interface. if interface and (address := self.cleaned_data.get('address')): if address.ip == address.network: - msg = _("{address} is a network ID, which may not be assigned to an interface.").format(address=address) + msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip) if address.version == 4 and address.prefixlen not in (31, 32): raise ValidationError(msg) if address.version == 6 and address.prefixlen not in (127, 128): raise ValidationError(msg) if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): - msg = _("{address} is a broadcast address, which may not be assigned to an interface.").format( - address=address + msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format( + ip=address.ip ) raise ValidationError(msg) @@ -728,7 +731,7 @@ class ServiceCreateForm(ServiceForm): class Meta(ServiceForm.Meta): fields = [ 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', - 'tags', + 'comments', 'tags', ] def __init__(self, *args, **kwargs): diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 5d355102f..1e4e7dac3 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -1,13 +1,12 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from netbox.models import ChangeLoggedModel, PrimaryModel from ipam.choices import * from ipam.constants import * +from netbox.models import ChangeLoggedModel, PrimaryModel __all__ = ( 'FHRPGroup', @@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel): class FHRPGroupAssignment(ChangeLoggedModel): interface_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) interface_id = models.PositiveBigIntegerField() diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 553f5eb92..7dc0ac445 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,6 +1,5 @@ import netaddr from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.db.models import F @@ -9,6 +8,7 @@ from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -140,8 +140,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): if covering_aggregates: raise ValidationError({ 'prefix': _( - "Aggregates cannot overlap. {} is already covered by an existing aggregate ({})." - ).format(self.prefix, covering_aggregates[0]) + "Aggregates cannot overlap. {prefix} is already covered by an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covering_aggregates[0] + ) }) # Ensure that the aggregate being added does not cover an existing aggregate @@ -150,8 +153,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: raise ValidationError({ - 'prefix': _("Aggregates cannot overlap. {} covers an existing aggregate ({}).").format( - self.prefix, covered_aggregates[0] + 'prefix': _( + "Prefixes cannot overlap aggregates. {prefix} covers an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covered_aggregates[0] ) }) @@ -290,8 +296,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): super().__init__(*args, **kwargs) # Cache the original prefix and VRF so we can check if they have changed on post_save - self._prefix = self.prefix - self._vrf_id = self.vrf_id + self._prefix = self.__dict__.get('prefix') + self._vrf_id = self.__dict__.get('vrf_id') def __str__(self): return str(self.prefix) @@ -314,10 +320,11 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: + table = _("VRF {vrf}").format(vrf=self.vrf) if self.vrf else _("global table") raise ValidationError({ - 'prefix': _("Duplicate prefix found in {}: {}").format( - _("VRF {}").format(self.vrf) if self.vrf else _("global table"), - duplicate_prefixes.first(), + 'prefix': _("Duplicate prefix found in {table}: {prefix}").format( + table=table, + prefix=duplicate_prefixes.first(), ) }) @@ -554,25 +561,13 @@ class IPRange(PrimaryModel): # Check that start & end IP versions match if self.start_address.version != self.end_address.version: raise ValidationError({ - 'end_address': _( - "Ending address version (IPv{end_address_version}) does not match starting address " - "(IPv{start_address_version})" - ).format( - end_address_version=self.end_address.version, - start_address_version=self.start_address.version - ) + 'end_address': _("Starting and ending IP address versions must match") }) # Check that the start & end IP prefix lengths match if self.start_address.prefixlen != self.end_address.prefixlen: raise ValidationError({ - 'end_address': _( - "Ending address mask (/{end_address_prefixlen}) does not match starting address mask " - "(/{start_address_prefixlen})" - ).format( - end_address_prefixlen=self.end_address.prefixlen, - start_address_prefixlen=self.start_address.prefixlen - ) + 'end_address': _("Starting and ending IP address masks must match") }) # Check that the ending address is greater than the starting address @@ -745,7 +740,7 @@ class IPAddress(PrimaryModel): help_text=_('The functional role of this IP') ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', @@ -794,6 +789,13 @@ class IPAddress(PrimaryModel): def __str__(self): return str(self.address) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Denote the original assigned object (if any) for validation in clean() + self._original_assigned_object_id = self.__dict__.get('assigned_object_id') + self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') + def get_absolute_url(self): return reverse('ipam:ipaddress', args=[self.pk]) @@ -848,13 +850,34 @@ class IPAddress(PrimaryModel): self.role not in IPADDRESS_ROLES_NONUNIQUE or any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips) ): + table = _("VRF {vrf}").format(vrf=self.vrf) if self.vrf else _("global table") raise ValidationError({ - 'address': _("Duplicate IP address found in {}: {}").format( - _("VRF {}").format(self.vrf) if self.vrf else _("global table"), - duplicate_ips.first(), + 'address': _("Duplicate IP address found in {table}: {ipaddress}").format( + table=table, + ipaddress=duplicate_ips.first(), ) }) + if self._original_assigned_object_id and self._original_assigned_object_type_id: + parent = getattr(self.assigned_object, 'parent_object', None) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) + original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) + original_parent = getattr(original_assigned_object, 'parent_object', None) + + # can't use is_primary_ip as self.assigned_object might be changed + is_primary = False + if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk: + is_primary = True + if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk: + is_primary = True + + if is_primary and (parent != original_parent): + raise ValidationError({ + 'assigned_object': _( + "Cannot reassign IP address while it is designated as the primary IP for the parent object" + ) + }) + # Validate IP status selection if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: raise ValidationError({ @@ -892,7 +915,7 @@ class IPAddress(PrimaryModel): def is_oob_ip(self): if self.assigned_object: parent = getattr(self.assigned_object, 'parent_object', None) - if parent.oob_ip_id == self.pk: + if hasattr(parent, 'oob_ip') and parent.oob_ip_id == self.pk: return True return False @@ -900,9 +923,9 @@ class IPAddress(PrimaryModel): def is_primary_ip(self): if self.assigned_object: parent = getattr(self.assigned_object, 'parent_object', None) - if self.family == 4 and parent.primary_ip4_id == self.pk: + if self.family == 4 and hasattr(parent, 'primary_ip4') and parent.primary_ip4_id == self.pk: return True - if self.family == 6 and parent.primary_ip6_id == self.pk: + if self.family == 6 and hasattr(parent, 'primary_ip6') and parent.primary_ip6_id == self.pk: return True return False diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 2c163179e..0e4818bea 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -1,11 +1,11 @@ from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS from netbox.models import NetBoxModel, PrimaryModel @@ -93,7 +93,7 @@ class L2VPNTermination(NetBoxModel): blank=True, ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+' diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index aa5b36a57..b6aed5398 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel): max_length=100 ) scope_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), blank=True, @@ -234,8 +233,8 @@ class VLAN(PrimaryModel): if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: raise ValidationError({ 'vid': _( - "VID must be between {min_vid} and {max_vid} for VLANs in group {group}" - ).format(min_vid=self.group.min_vid, max_vid=self.group.max_vid, group=self.group) + "VID must be between {minimum} and {maximum} for VLANs in group {group}" + ).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group) }) def get_status_color(self): diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 4d97bf5f0..c08acce1b 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -11,6 +11,7 @@ class AggregateIndex(SearchIndex): ('date_added', 2000), ('comments', 5000), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -20,6 +21,7 @@ class ASNIndex(SearchIndex): ('asn', 100), ('description', 500), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -28,6 +30,7 @@ class ASNRangeIndex(SearchIndex): fields = ( ('description', 500), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -39,6 +42,7 @@ class FHRPGroupIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('protocol', 'auth_type', 'description') @register_search @@ -50,6 +54,7 @@ class IPAddressIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') @register_search @@ -61,6 +66,7 @@ class IPRangeIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') @register_search @@ -72,6 +78,7 @@ class L2VPNIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'identifier', 'tenant', 'description') @register_search @@ -82,6 +89,7 @@ class PrefixIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description') @register_search @@ -92,6 +100,7 @@ class RIRIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -102,6 +111,7 @@ class RoleIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -112,6 +122,7 @@ class RouteTargetIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('tenant', 'description') @register_search @@ -122,6 +133,7 @@ class ServiceIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('device', 'virtual_machine', 'description') @register_search @@ -132,6 +144,7 @@ class ServiceTemplateIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('description',) @register_search @@ -143,6 +156,7 @@ class VLANIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'group', 'tenant', 'status', 'role', 'description') @register_search @@ -154,6 +168,7 @@ class VLANGroupIndex(SearchIndex): ('description', 500), ('max_vid', 2000), ) + display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description') @register_search @@ -165,3 +180,4 @@ class VRFIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('rd', 'tenant', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index cfc9faac1..f62f79f4a 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): ) IPAddress.objects.bulk_create(ip_addresses) + def test_assign_object(self): + """ + Test the creation of available IP addresses within a parent IP range. + """ + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + role = DeviceRole.objects.create(name='Switch') + device1 = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset') + interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset') + device2 = Device.objects.create( + name='Device 2', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset') + + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1), + IPAddress(address=IPNetwork('192.168.1.4/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + + ip1 = ip_addresses[0] + ip1.assigned_object = interface1 + device1.primary_ip4 = ip_addresses[0] + device1.save() + + ip2 = ip_addresses[1] + + url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk}) + self.add_permissions('ipam.change_ipaddress') + + # assign to same parent + data = { + 'assigned_object_id': interface2.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # assign to same different parent - should error + data = { + 'assigned_object_id': interface3.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class FHRPGroupTest(APIViewTestCases.APIViewTestCase): model = FHRPGroup diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c1ba4c4ff..4e4eb514f 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': ['24']} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'mask_length__gte': 32} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__lte': 24} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) def test_vrf(self): vrfs = VRF.objects.all()[:2] @@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': '24'} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__gte': 64} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'mask_length__lte': 25} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_vrf(self): vrfs = VRF.objects.all()[:2] diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index afc97cc63..a37584f0f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,6 +4,7 @@ from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork +from dcim.constants import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface from ipam.choices import * from ipam.models import * @@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role) + interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL) services = ( Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), @@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Service.objects.bulk_create(services) + ip_addresses = ( + IPAddress(assigned_object=interface, address='192.0.2.1/24'), + IPAddress(assigned_object=interface, address='192.0.2.2/24'), + ) + IPAddress.objects.bulk_create(ip_addresses) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "device,name,protocol,ports,description", - "Device 1,Service 1,tcp,1,First service", - "Device 1,Service 2,tcp,2,Second service", - "Device 1,Service 3,udp,3,Third service", + "device,name,protocol,ports,ipaddresses,description", + "Device 1,Service 1,tcp,1,192.0.2.1/24,First service", + "Device 1,Service 2,tcp,2,192.0.2.2/24,Second service", + "Device 1,Service 3,udp,3,,Third service", ) cls.csv_update_data = ( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 490cf940b..48ea637d9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,7 +1,6 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import F, Prefetch from django.db.models.expressions import RawSQL -from django.db.models.functions import Round from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext as _ @@ -11,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic from tenancy.views import ObjectContactsView +from utilities.tables import get_table_ordering from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet @@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView): tab = ViewTab( label=_('ASNs'), badge=lambda x: x.get_child_asns().count(), - permission='ipam.view_asns', + permission='ipam.view_asn', weight=500 ) @@ -606,7 +606,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') def prep_table_data(self, request, queryset, parent): - if not request.GET.get('q') and not request.GET.get('sort'): + if not get_table_ordering(request, self.table): return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool) return queryset @@ -952,7 +952,9 @@ class VLANGroupVLANsView(generic.ObjectChildrenView): ) def prep_table_data(self, request, queryset, parent): - return add_available_vlans(parent.get_child_vlans(), parent) + if not get_table_ordering(request, self.table): + return add_available_vlans(parent.get_child_vlans(), parent) + return queryset # diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 347ed55bd..d6e43ea75 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -46,12 +46,13 @@ class ChoiceField(serializers.Field): return super().validate_empty_values(data) def to_representation(self, obj): - if obj == '': - return None - return { - 'value': obj, - 'label': self._choices[obj], - } + if obj != '': + # Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously + # configured choice has been removed from FIELD_CHOICES). + return { + 'value': obj, + 'label': self._choices.get(obj, ''), + } def to_internal_value(self, data): if data == '': diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 97f690762..4e71ca193 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -11,7 +11,7 @@ from rest_framework.reverse import reverse from rest_framework.views import APIView 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 diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 5fe81b1f5..522bcf77b 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -2,7 +2,9 @@ import logging from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction -from django.db.models import ProtectedError +from django.db.models import ProtectedError, RestrictedError +from django_pglocks import advisory_lock +from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -89,8 +91,11 @@ class NetBoxModelViewSet( try: return super().dispatch(request, *args, **kwargs) - except ProtectedError as e: - protected_objects = list(e.protected_objects) + except (ProtectedError, RestrictedError) as e: + if type(e) is ProtectedError: + protected_objects = list(e.protected_objects) + else: + protected_objects = list(e.restricted_objects) msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) logger.warning(msg) @@ -157,3 +162,22 @@ class NetBoxModelViewSet( logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") return super().perform_destroy(instance) + + +class MPTTLockedMixin: + """ + Puts pglock on objects that derive from MPTTModel for parallel API calling. + Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS + """ + + def create(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().destroy(request, *args, **kwargs) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index fde486fe9..7b6c00843 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -137,11 +137,14 @@ class BulkUpdateModelMixin: } ] """ + def get_bulk_update_queryset(self): + return self.get_queryset() + def bulk_update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_update_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) @@ -184,10 +187,13 @@ class BulkDestroyModelMixin: {"id": 456} ] """ + def get_bulk_destroy_queryset(self): + return self.get_queryset() + def bulk_destroy(self, request, *args, **kwargs): serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_destroy_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 8be5c97a9..0cdf8a8d2 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -152,42 +152,17 @@ PARAMS = ( description=_("Custom validation rules (JSON)"), field=forms.JSONField, field_kwargs={ - 'widget': forms.Textarea( - attrs={'class': 'vLargeTextField'} - ), + 'widget': forms.Textarea(), }, ), - - # NAPALM ConfigParam( - name='NAPALM_USERNAME', - label=_('NAPALM username'), - default='', - description=_("Username to use when connecting to devices via NAPALM") - ), - ConfigParam( - name='NAPALM_PASSWORD', - label=_('NAPALM password'), - default='', - description=_("Password to use when connecting to devices via NAPALM") - ), - ConfigParam( - name='NAPALM_TIMEOUT', - label=_('NAPALM timeout'), - default=30, - description=_("NAPALM connection timeout (in seconds)"), - field=forms.IntegerField - ), - ConfigParam( - name='NAPALM_ARGS', - label=_('NAPALM arguments'), + name='PROTECTION_RULES', + label=_('Protection rules'), default={}, - description=_("Additional arguments to pass when invoking a NAPALM driver (as JSON data)"), + description=_("Deletion protection rules (JSON)"), field=forms.JSONField, field_kwargs={ - 'widget': forms.Textarea( - attrs={'class': 'vLargeTextField'} - ), + 'widget': forms.Textarea(), }, ), diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 18a3c2afa..cec05cabb 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -15,7 +15,7 @@ DATABASE = { } PLUGINS = [ - 'extras.tests.dummy_plugin', + 'netbox.tests.dummy_plugin', ] REDIS = { diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index d69edc69c..faddf8c21 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -11,8 +11,28 @@ RQ_QUEUE_LOW = 'low' # When adding a new key, pick something arbitrary and unique so that it is easily searchable in # query logs. ADVISORY_LOCK_KEYS = { + # Available object locks 'available-prefixes': 100100, 'available-ips': 100200, 'available-vlans': 100300, 'available-asns': 100400, + + # MPTT locks + 'region': 105100, + 'sitegroup': 105200, + 'location': 105300, + 'tenantgroup': 105400, + 'contactgroup': 105500, + 'wirelesslangroup': 105600, + 'inventoryitem': 105700, + 'inventoryitemtemplate': 105800, +} + +# Default view action permission mapping +DEFAULT_ACTION_PERMISSIONS = { + 'add': {'add'}, + 'import': {'add'}, + 'export': {'view'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, } diff --git a/netbox/netbox/data_backends.py b/netbox/netbox/data_backends.py new file mode 100644 index 000000000..d5bab75c1 --- /dev/null +++ b/netbox/netbox/data_backends.py @@ -0,0 +1,53 @@ +from contextlib import contextmanager +from urllib.parse import urlparse + +__all__ = ( + 'DataBackend', +) + + +class DataBackend: + """ + A data backend represents a specific system of record for data, such as a git repository or Amazon S3 bucket. + + Attributes: + name: The identifier under which this backend will be registered in NetBox + label: The human-friendly name for this backend + is_local: A boolean indicating whether this backend accesses local data + parameters: A dictionary mapping configuration form field names to their classes + sensitive_parameters: An iterable of field names for which the values should not be displayed to the user + """ + is_local = False + parameters = {} + sensitive_parameters = [] + + # Prevent Django's template engine from calling the backend + # class when referenced via DataSource.backend_class + do_not_call_in_templates = True + + def __init__(self, url, **kwargs): + self.url = url + self.params = kwargs + self.config = self.init_config() + + def init_config(self): + """ + A hook to initialize the instance's configuration. The data returned by this method is assigned to the + instance's `config` attribute upon initialization, which can be referenced by the `fetch()` method. + """ + return + + @property + def url_scheme(self): + return urlparse(self.url).scheme.lower() + + @contextmanager + def fetch(self): + """ + A context manager which performs the following: + + 1. Handles all setup and synchronization + 2. Yields the local path at which data has been replicated + 3. Performs any necessary cleanup + """ + raise NotImplemented() diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index c5dac90f7..b51efe9c0 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -3,11 +3,12 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices -from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin +from extras.choices import * +from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin from extras.models import CustomField, Tag -from utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin +from utilities.forms import CSVModelForm from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin __all__ = ( 'NetBoxModelForm', @@ -17,7 +18,7 @@ __all__ = ( ) -class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, forms.ModelForm): +class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm): """ Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. @@ -26,18 +27,6 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, the rendered form (optional). If not defined, the all fields will be rendered as a single section. """ fieldsets = () - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False, - label=_('Tags'), - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit tags to those applicable to the object type - if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'): - self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk) def _get_content_type(self): return ContentType.objects.get_for_model(self._meta.model) @@ -87,11 +76,9 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): ) def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type).filter( - ui_visibility__in=[ - CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET, - ] + return CustomField.objects.filter( + content_types=content_type, + ui_editable=CustomFieldUIEditableChoices.YES ) def _get_form_field(self, customfield): @@ -142,7 +129,8 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): def _extend_nullable_fields(self): nullable_custom_fields = [ - name for name, customfield in self.custom_fields.items() if (not customfield.required and customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE) + name for name, customfield in self.custom_fields.items() + if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES) ] self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 596357ea4..9d7696696 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey +from django.core.exceptions import ObjectDoesNotExist from django.core.validators import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): if ct_value and fk_value: klass = getattr(self, field.ct_field).model_class() - if not klass.objects.filter(pk=fk_value).exists(): + try: + obj = klass.objects.get(pk=fk_value) + except ObjectDoesNotExist: raise ValidationError({ field.fk_field: f"Related object not found using the provided value: {fk_value}." }) + # update the GFK field value + setattr(self, field.name, obj) + # # NetBox internal base models diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index cce265efc..f39f35620 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -3,7 +3,6 @@ from collections import defaultdict from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.db.models.signals import class_prepared @@ -13,7 +12,8 @@ from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager from core.choices import JobStatusChoices -from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices +from core.models import ContentType +from extras.choices import * from extras.utils import is_taggable, register_features from netbox.registry import registry from netbox.signals import post_clean @@ -205,12 +205,11 @@ class CustomFieldsMixin(models.Model): for field in CustomField.objects.get_for_model(self): value = self.custom_field_data.get(field.name) - # Skip fields that are hidden if 'omit_hidden' is set - if omit_hidden: - if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value: - continue + # Skip hidden fields if 'omit_hidden' is True + if omit_hidden and field.ui_visible == CustomFieldUIVisibleChoices.HIDDEN: + continue + elif omit_hidden and field.ui_visible == CustomFieldUIVisibleChoices.IF_SET and not value: + continue data[field] = field.deserialize(value) @@ -232,12 +231,12 @@ class CustomFieldsMixin(models.Model): from extras.models import CustomField groups = defaultdict(dict) visible_custom_fields = CustomField.objects.get_for_model(self).exclude( - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN ) for cf in visible_custom_fields: value = self.custom_field_data.get(cf.name) - if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET: + if value in (None, []) and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET: continue value = cf.deserialize(value) groups[cf.group_name][cf] = value diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index a05b1c495..4c7190bbb 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -34,6 +34,7 @@ class MenuItem: link: str link_text: str permissions: Optional[Sequence[str]] = () + staff_only: Optional[bool] = False buttons: Optional[Sequence[MenuItemButton]] = () diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 5c7502a03..43cf3f869 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,4 +1,4 @@ -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.registry import registry from utilities.choices import ButtonColorChoices @@ -218,6 +218,7 @@ VIRTUALIZATION_MENU = Menu( items=( get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')), get_model_item('virtualization', 'vminterface', _('Interfaces')), + get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')), ), ), MenuGroup( @@ -360,6 +361,7 @@ ADMIN_MENU = Menu( link=f'users:netboxuser_list', link_text=_('Users'), permissions=[f'auth.view_user'], + staff_only=True, buttons=( MenuItemButton( link=f'users:netboxuser_add', @@ -382,6 +384,7 @@ ADMIN_MENU = Menu( link=f'users:netboxgroup_list', link_text=_('Groups'), permissions=[f'auth.view_group'], + staff_only=True, buttons=( MenuItemButton( link=f'users:netboxgroup_add', @@ -399,8 +402,20 @@ ADMIN_MENU = Menu( ) ) ), - get_model_item('users', 'token', _('API Tokens')), - get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), + MenuItem( + link=f'users:token_list', + link_text=_('API Tokens'), + permissions=[f'users.view_token'], + staff_only=True, + buttons=get_model_buttons('users', 'token') + ), + MenuItem( + link=f'users:objectpermission_list', + link_text=_('Permissions'), + permissions=[f'users.view_objectpermission'], + staff_only=True, + buttons=get_model_buttons('users', 'objectpermission', actions=['add']) + ), ), ), MenuGroup( @@ -409,12 +424,14 @@ ADMIN_MENU = Menu( MenuItem( link='core:config', link_text=_('Current Config'), - permissions=['extras.view_configrevision'] + permissions=['extras.view_configrevision'], + staff_only=True ), MenuItem( link='extras:configrevision_list', link_text=_('Config Revisions'), - permissions=['extras.view_configrevision'] + permissions=['extras.view_configrevision'], + staff_only=True ), ), ), diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py new file mode 100644 index 000000000..8b6901b7a --- /dev/null +++ b/netbox/netbox/plugins/__init__.py @@ -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 diff --git a/netbox/netbox/plugins/navigation.py b/netbox/netbox/plugins/navigation.py new file mode 100644 index 000000000..2075c97b6 --- /dev/null +++ b/netbox/netbox/plugins/navigation.py @@ -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 diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py new file mode 100644 index 000000000..3be538441 --- /dev/null +++ b/netbox/netbox/plugins/registration.py @@ -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 diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py new file mode 100644 index 000000000..e9b9a9dca --- /dev/null +++ b/netbox/netbox/plugins/templates.py @@ -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 '
{% blocktrans %}Swap these terminations for circuit {{ circuit }}?{% endblocktrans %}
++ {% blocktrans trimmed %} + Swap these terminations for circuit {{ circuit }}? + {% endblocktrans %} +
- {% blocktrans with count=selected_objects|length %} + {% blocktrans trimmed with count=selected_objects|length %} Are you sure you want to disconnect these {{ count }} {{ obj_type_plural }}? {% endblocktrans %}
diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 12000f09d..b004634bb 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -3,7 +3,7 @@ {% load i18n %} {% block title %} - {% blocktrans with object_type=object|meta:"verbose_name"|bettertitle %} + {% blocktrans trimmed with object_type=object|meta:"verbose_name"|bettertitle %} Cable Trace for {{ object_type }} {{ object }} {% endblocktrans %} {% endblock %} @@ -23,7 +23,15 @@{% trans "The nodes below have no links and result in an asymmetric path" %}:
+{% trans "Select a node below to continue" %}:
{% blocktrans %}Are you sure you want to delete this device bay from {{ devicebay.device }}?{% endblocktrans %}
++ {% blocktrans trimmed with device=devicebay.device %} + Are you sure you want to delete this device bay from {{ device }}? + {% endblocktrans %} +
{% endblock %} diff --git a/netbox/templates/dcim/devicebay_depopulate.html b/netbox/templates/dcim/devicebay_depopulate.html index a0c026800..b094f5993 100644 --- a/netbox/templates/dcim/devicebay_depopulate.html +++ b/netbox/templates/dcim/devicebay_depopulate.html @@ -3,14 +3,14 @@ {% load i18n %} {% block title %} - {% blocktrans with device=device_bay.installed_device %} + {% blocktrans trimmed with device=device_bay.installed_device %} Remove {{ device }} from {{ device_bay }}? {% endblocktrans %} {% endblock %} {% block message %}- {% blocktrans with device=device_bay.installed_device %} + {% blocktrans trimmed with device=device_bay.installed_device %} Are you sure you want to remove {{ device }} from {{ device_bay }}? {% endblocktrans %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 419ab7f00..35b089664 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -40,6 +40,10 @@