diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index ec7d667e6..f4afe3f98 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.1 + placeholder: v3.6.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index dc27ebd26..9bf991e6e 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.1 + placeholder: v3.6.4 validations: required: true - type: dropdown 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/tables/circuits.py b/netbox/circuits/tables/circuits.py
index 6a05983e6..6ae727eca 100644
--- a/netbox/circuits/tables/circuits.py
+++ b/netbox/circuits/tables/circuits.py
@@ -28,6 +28,7 @@ class CircuitTypeTable(NetBoxTable):
linkify=True,
verbose_name=_('Name'),
)
+ color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='circuits:circuittype_list'
)
@@ -40,7 +41,7 @@ class CircuitTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = CircuitType
fields = (
- 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
+ 'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py
index 4117a609c..4ae426df5 100644
--- a/netbox/core/api/serializers.py
+++ b/netbox/core/api/serializers.py
@@ -4,6 +4,7 @@ from core.choices import *
from core.models import *
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
+from netbox.utils import get_data_backend_choices
from users.api.nested_serializers import NestedUserSerializer
from .nested_serializers import *
@@ -19,7 +20,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
view_name='core-api:datasource-detail'
)
type = ChoiceField(
- choices=DataSourceTypeChoices
+ choices=get_data_backend_choices()
)
status = ChoiceField(
choices=DataSourceStatusChoices,
@@ -68,5 +69,5 @@ class JobSerializer(BaseModelSerializer):
model = Job
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
- 'started', 'completed', 'user', 'data', 'job_id',
+ 'started', 'completed', 'user', 'data', 'error', 'job_id',
]
diff --git a/netbox/core/choices.py b/netbox/core/choices.py
index 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..4d0acbb77 100644
--- a/netbox/core/forms/filtersets.py
+++ b/netbox/core/forms/filtersets.py
@@ -8,6 +8,7 @@ from core.models import *
from extras.forms.mixins import SavedFiltersMixin
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelFilterSetForm
+from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
@@ -27,7 +28,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
)
type = forms.MultipleChoiceField(
label=_('Type'),
- choices=DataSourceTypeChoices,
+ choices=get_data_backend_choices,
required=False
)
status = forms.MultipleChoiceField(
diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py
index 01d5474c6..e3184acf6 100644
--- a/netbox/core/forms/model_forms.py
+++ b/netbox/core/forms/model_forms.py
@@ -7,6 +7,7 @@ from core.forms.mixins import SyncedDataMixin
from core.models import *
from netbox.forms import NetBoxModelForm
from netbox.registry import registry
+from netbox.utils import get_data_backend_choices
from utilities.forms import get_field_value
from utilities.forms.fields import CommentField
from utilities.forms.widgets import HTMXSelect
@@ -18,6 +19,10 @@ __all__ = (
class DataSourceForm(NetBoxModelForm):
+ type = forms.ChoiceField(
+ choices=get_data_backend_choices,
+ widget=HTMXSelect()
+ )
comments = CommentField()
class Meta:
@@ -26,7 +31,6 @@ class DataSourceForm(NetBoxModelForm):
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
]
widgets = {
- 'type': HTMXSelect(),
'ignore_rules': forms.Textarea(
attrs={
'rows': 5,
@@ -56,12 +60,13 @@ class DataSourceForm(NetBoxModelForm):
# Add backend-specific form fields
self.backend_fields = []
- for name, form_field in backend.parameters.items():
- field_name = f'backend_{name}'
- self.backend_fields.append(field_name)
- self.fields[field_name] = copy.copy(form_field)
- if self.instance and self.instance.parameters:
- self.fields[field_name].initial = self.instance.parameters.get(name)
+ if backend:
+ for name, form_field in backend.parameters.items():
+ field_name = f'backend_{name}'
+ self.backend_fields.append(field_name)
+ self.fields[field_name] = copy.copy(form_field)
+ if self.instance and self.instance.parameters:
+ self.fields[field_name].initial = self.instance.parameters.get(name)
def save(self, *args, **kwargs):
diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py
index d25981920..32b546b20 100644
--- a/netbox/core/jobs.py
+++ b/netbox/core/jobs.py
@@ -25,7 +25,7 @@ def sync_datasource(job, *args, **kwargs):
job.terminate()
except Exception as e:
- job.terminate(status=JobStatusChoices.STATUS_ERRORED)
+ job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) in (SyncError, JobTimeoutException):
logging.error(e)
diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py
new file mode 100644
index 000000000..0ad8d8854
--- /dev/null
+++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.6 on 2023-10-20 17:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0005_job_created_auto_now'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='datasource',
+ name='type',
+ field=models.CharField(max_length=50),
+ ),
+ ]
diff --git a/netbox/core/migrations/0007_job_add_error_field.py b/netbox/core/migrations/0007_job_add_error_field.py
new file mode 100644
index 000000000..e2e173bfd
--- /dev/null
+++ b/netbox/core/migrations/0007_job_add_error_field.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.6 on 2023-10-23 20:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0006_datasource_type_remove_choices'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='job',
+ name='error',
+ field=models.TextField(blank=True, editable=False),
+ ),
+ ]
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 54a43c7ef..fb764134a 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -45,9 +45,7 @@ class DataSource(JobsMixin, PrimaryModel):
)
type = models.CharField(
verbose_name=_('type'),
- max_length=50,
- choices=DataSourceTypeChoices,
- default=DataSourceTypeChoices.LOCAL
+ max_length=50
)
source_url = models.CharField(
max_length=200,
@@ -96,8 +94,9 @@ class DataSource(JobsMixin, PrimaryModel):
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
- def get_type_color(self):
- return DataSourceTypeChoices.colors.get(self.type)
+ def get_type_display(self):
+ if backend := registry['data_backends'].get(self.type):
+ return backend.label
def get_status_color(self):
return DataSourceStatusChoices.colors.get(self.status)
@@ -110,10 +109,6 @@ class DataSource(JobsMixin, PrimaryModel):
def backend_class(self):
return registry['data_backends'].get(self.type)
- @property
- def is_local(self):
- return self.type == DataSourceTypeChoices.LOCAL
-
@property
def ready_for_sync(self):
return self.enabled and self.status not in (
@@ -123,8 +118,14 @@ class DataSource(JobsMixin, PrimaryModel):
def clean(self):
+ # Validate data backend type
+ if self.type and self.type not in registry['data_backends']:
+ raise ValidationError({
+ 'type': _("Unknown backend type: {type}".format(type=self.type))
+ })
+
# Ensure URL scheme matches selected type
- if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
+ if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
})
diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py
index 61b0e64fa..4e9a93bfb 100644
--- a/netbox/core/models/jobs.py
+++ b/netbox/core/models/jobs.py
@@ -92,6 +92,11 @@ class Job(models.Model):
null=True,
blank=True
)
+ error = models.TextField(
+ verbose_name=_('error'),
+ editable=False,
+ blank=True
+ )
job_id = models.UUIDField(
verbose_name=_('job ID'),
unique=True
@@ -158,7 +163,7 @@ class Job(models.Model):
# Handle webhooks
self.trigger_webhooks(event=EVENT_JOB_START)
- def terminate(self, status=JobStatusChoices.STATUS_COMPLETED):
+ def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
"""
Mark the job as completed, optionally specifying a particular termination status.
"""
@@ -168,6 +173,8 @@ class Job(models.Model):
# Mark the job as completed
self.status = status
+ if error:
+ self.error = error
self.completed = timezone.now()
self.save()
diff --git a/netbox/core/tables/columns.py b/netbox/core/tables/columns.py
new file mode 100644
index 000000000..93f1e3901
--- /dev/null
+++ b/netbox/core/tables/columns.py
@@ -0,0 +1,20 @@
+import django_tables2 as tables
+
+from netbox.registry import registry
+
+__all__ = (
+ 'BackendTypeColumn',
+)
+
+
+class BackendTypeColumn(tables.Column):
+ """
+ Display a data backend type.
+ """
+ def render(self, value):
+ if backend := registry['data_backends'].get(value):
+ return backend.label
+ return value
+
+ def value(self, value):
+ return value
diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py
index 1ecc42369..4059ea9bc 100644
--- a/netbox/core/tables/data.py
+++ b/netbox/core/tables/data.py
@@ -3,6 +3,7 @@ import django_tables2 as tables
from core.models import *
from netbox.tables import NetBoxTable, columns
+from .columns import BackendTypeColumn
__all__ = (
'DataFileTable',
@@ -15,8 +16,8 @@ class DataSourceTable(NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
- type = columns.ChoiceFieldColumn(
- verbose_name=_('Type'),
+ type = BackendTypeColumn(
+ verbose_name=_('Type')
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
@@ -34,8 +35,8 @@ class DataSourceTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = DataSource
fields = (
- 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
- 'last_updated', 'file_count',
+ 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
+ 'created', 'last_updated', 'file_count',
)
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py
index 32ca67f7f..3388aee19 100644
--- a/netbox/core/tables/jobs.py
+++ b/netbox/core/tables/jobs.py
@@ -47,7 +47,7 @@ class JobTable(NetBoxTable):
model = Job
fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
- 'completed', 'user', 'job_id',
+ 'completed', 'user', 'error', 'job_id',
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py
index dc6d6a5ce..cd25761f0 100644
--- a/netbox/core/tests/test_api.py
+++ b/netbox/core/tests/test_api.py
@@ -2,7 +2,6 @@ from django.urls import reverse
from django.utils import timezone
from utilities.testing import APITestCase, APIViewTestCases
-from ..choices import *
from ..models import *
@@ -26,26 +25,26 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
data_sources = (
- DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
- DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
- DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+ DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
+ DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
+ DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
cls.create_data = [
{
'name': 'Data Source 4',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'https://example.com/git/source4'
},
{
'name': 'Data Source 5',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'https://example.com/git/source5'
},
{
'name': 'Data Source 6',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'https://example.com/git/source6'
},
]
@@ -63,7 +62,7 @@ class DataFileTest(
def setUpTestData(cls):
datasource = DataSource.objects.create(
name='Data Source 1',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source1/'
)
diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py
index e1e916f70..2f60c7522 100644
--- a/netbox/core/tests/test_filtersets.py
+++ b/netbox/core/tests/test_filtersets.py
@@ -18,21 +18,21 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
data_sources = (
DataSource(
name='Data Source 1',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source1/',
status=DataSourceStatusChoices.NEW,
enabled=True
),
DataSource(
name='Data Source 2',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source2/',
status=DataSourceStatusChoices.SYNCING,
enabled=True
),
DataSource(
name='Data Source 3',
- type=DataSourceTypeChoices.GIT,
+ type='git',
source_url='https://example.com/git/source3',
status=DataSourceStatusChoices.COMPLETED,
enabled=False
@@ -45,7 +45,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
- params = {'type': [DataSourceTypeChoices.LOCAL]}
+ params = {'type': ['local']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
@@ -66,9 +66,9 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
data_sources = (
- DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
- DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
- DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+ DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
+ DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
+ DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py
index 4a50a8d05..16d07f376 100644
--- a/netbox/core/tests/test_views.py
+++ b/netbox/core/tests/test_views.py
@@ -1,7 +1,6 @@
from django.utils import timezone
from utilities.testing import ViewTestCases, create_tags
-from ..choices import *
from ..models import *
@@ -11,9 +10,9 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
data_sources = (
- DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
- DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
- DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
+ DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
+ DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
+ DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
@@ -21,7 +20,7 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'name': 'Data Source X',
- 'type': DataSourceTypeChoices.GIT,
+ 'type': 'git',
'source_url': 'http:///exmaple/com/foo/bar/',
'description': 'Something',
'comments': 'Foo bar baz',
@@ -29,10 +28,10 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- f"name,type,source_url,enabled",
- f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
- f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
- f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false",
+ "name,type,source_url,enabled",
+ "Data Source 4,local,file:///var/tmp/source4/,true",
+ "Data Source 5,local,file:///var/tmp/source4/,true",
+ "Data Source 6,git,http:///exmaple/com/foo/bar/,false",
)
cls.csv_update_data = (
@@ -60,7 +59,7 @@ class DataFileTestCase(
def setUpTestData(cls):
datasource = DataSource.objects.create(
name='Data Source 1',
- type=DataSourceTypeChoices.LOCAL,
+ type='local',
source_url='file:///var/tmp/source1/'
)
diff --git a/netbox/core/views.py b/netbox/core/views.py
index e3c1a67aa..d16fa4ece 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -100,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)
@@ -128,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):
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index b43611dad..32dcdc5bb 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
model = DeviceType
fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
- 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
- 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
- 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
+ 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+ 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count',
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index f045f1bb4..a3e532f0b 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -20,10 +20,11 @@ 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 +99,7 @@ class PassThroughPortMixin(object):
# Regions
#
-class RegionViewSet(NetBoxModelViewSet):
+class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
@@ -114,7 +115,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 +150,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 +351,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
@@ -505,6 +506,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
filterset_class = filtersets.InterfaceFilterSet
brief_prefetch_fields = ['device']
+ def get_bulk_destroy_queryset(self):
+ # Ensure child interfaces are deleted prior to their parents
+ return self.get_queryset().order_by('device', 'parent', CollateAsChar('_name'))
+
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related(
@@ -538,7 +543,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..c65110d9a 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -496,7 +496,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = DeviceType
fields = [
- 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+ 'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role',
+ 'airflow', 'weight', 'weight_unit',
]
def search(self, queryset, name, value):
@@ -1745,6 +1746,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 +1817,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..3d626d201 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm):
fieldsets = (
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
(_('Chassis'), (
- 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
+ 'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
+ 'weight', 'weight_unit',
)),
(_('Images'), ('front_image', 'rear_image')),
)
@@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm):
class Meta:
model = DeviceType
fields = [
- 'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth',
- 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
- 'comments', 'tags',
+ 'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
+ 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
+ 'description', 'comments', 'tags',
]
widgets = {
'front_image': ClearableFileInput(attrs={
diff --git a/netbox/dcim/migrations/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..751bca271 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -20,7 +20,7 @@ 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 +98,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
@@ -518,9 +518,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 +536,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 +606,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 +673,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 +788,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..5110835f4 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -89,7 +89,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 +534,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
- _("Rear port ({}) must belong to the same device type").format(self.rear_port)
+ _("Rear port ({name}) must belong to the same device type").format(name=self.rear_port)
)
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
- _("Invalid rear port position ({}); rear port {} has only {} positions").format(
- self.rear_port_position, self.rear_port.name, self.rear_port.positions
+ _("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
+ position=self.rear_port_position,
+ name=self.rear_port.name,
+ count=self.rear_port.positions
)
)
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index e18f25e4f..94568459e 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -86,7 +86,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 +537,7 @@ class BaseInterface(models.Model):
)
parent = models.ForeignKey(
to='self',
- on_delete=models.SET_NULL,
+ on_delete=models.RESTRICT,
related_name='child_interfaces',
null=True,
blank=True,
@@ -799,9 +799,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 +889,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 +1067,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)
})
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/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..624eb579b 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/mixins.py b/netbox/extras/forms/mixins.py
index be45f5211..5366dcc28 100644
--- a/netbox/extras/forms/mixins.py
+++ b/netbox/extras/forms/mixins.py
@@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = (
'CustomFieldsMixin',
'SavedFiltersMixin',
+ 'TagsMixin',
)
@@ -72,3 +73,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..fd2ce8f2d 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -4,6 +4,7 @@ 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
@@ -75,13 +76,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 +93,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:
@@ -325,7 +328,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 +491,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
- (_('Validation'), ('CUSTOM_VALIDATORS',)),
+ (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
(_('Miscellaneous'), (
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
@@ -505,6 +508,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
+ 'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
'comment': forms.Textarea(),
}
@@ -515,22 +519,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/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index a8153e1bb..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 @@ -1176,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_views.py b/netbox/extras/tests/test_views.py index 01ef9a2a6..296ed9f4d 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 @@ -434,7 +434,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 +444,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 +452,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/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/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/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/filtersets.py b/netbox/ipam/forms/filtersets.py index e4e967f81..aae62ca75 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) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index c466e279f..dd9e6b3e4 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/ip.py b/netbox/ipam/models/ip.py index 89977704a..934cb98c7 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -140,8 +140,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): if covering_aggregates: raise ValidationError({ 'prefix': _( - "Aggregates cannot overlap. {} is already covered by an existing aggregate ({})." - ).format(self.prefix, covering_aggregates[0]) + "Aggregates cannot overlap. {prefix} is already covered by an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covering_aggregates[0] + ) }) # Ensure that the aggregate being added does not cover an existing aggregate @@ -150,8 +153,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: raise ValidationError({ - 'prefix': _("Aggregates cannot overlap. {} covers an existing aggregate ({}).").format( - self.prefix, covered_aggregates[0] + 'prefix': _( + "Prefixes cannot overlap aggregates. {prefix} covers an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covered_aggregates[0] ) }) @@ -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 @@ -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({ diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index aa5b36a57..675d03ee5 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -234,8 +234,8 @@ class VLAN(PrimaryModel): if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: raise ValidationError({ 'vid': _( - "VID must be between {min_vid} and {max_vid} for VLANs in group {group}" - ).format(min_vid=self.group.min_vid, max_vid=self.group.max_vid, group=self.group) + "VID must be between {minimum} and {maximum} for VLANs in group {group}" + ).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group) }) def get_status_color(self): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 24d219ca0..d696c8dae 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/views.py b/netbox/ipam/views.py index 490cf940b..7cf785521 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 @@ -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 31c4f693a..0cdf8a8d2 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -152,9 +152,17 @@ PARAMS = ( description=_("Custom validation rules (JSON)"), field=forms.JSONField, field_kwargs={ - 'widget': forms.Textarea( - attrs={'class': 'vLargeTextField'} - ), + 'widget': forms.Textarea(), + }, + ), + ConfigParam( + name='PROTECTION_RULES', + label=_('Protection rules'), + default={}, + description=_("Deletion protection rules (JSON)"), + field=forms.JSONField, + field_kwargs={ + 'widget': forms.Textarea(), }, ), diff --git a/netbox/netbox/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..43d0850f0 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -4,10 +4,11 @@ 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.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) 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/navigation/menu.py b/netbox/netbox/navigation/menu.py index 5b64cfc1e..961fd2035 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,4 +1,4 @@ -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.registry import registry from utilities.choices import ButtonColorChoices diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py 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 @@{% blocktrans %}This will remove all configured widgets and restore the default dashboard configuration.{% endblocktrans %}
-{% blocktrans %}This change affects only your dashboard, and will not impact other users.{% endblocktrans %}
++ {% blocktrans trimmed %} + This will remove all configured widgets and restore the default dashboard configuration. + {% endblocktrans %} +
++ {% blocktrans trimmed %} + This change affects only your dashboard, and will not impact other users. + {% endblocktrans %} +
{% endblock %} diff --git a/netbox/templates/extras/dashboard/widget.html b/netbox/templates/extras/dashboard/widget.html index 1559363d3..b8dec3de2 100644 --- a/netbox/templates/extras/dashboard/widget.html +++ b/netbox/templates/extras/dashboard/widget.html @@ -9,14 +9,16 @@ gs-id="{{ widget.id }}" >- {% blocktrans %}Required fields must be specified for all objects.{% endblocktrans %} + {% blocktrans trimmed %} + Required fields must be specified for all objects. + {% endblocktrans %}
- {% blocktrans with example="vrf.rd" %}Related objects may be referenced by any unique attribute. For example, {{ example }}
would identify a VRF by its route distinguisher.{% endblocktrans %}
+ {% blocktrans trimmed with example="vrf.rd" %}
+ Related objects may be referenced by any unique attribute. For example, {{ example }}
would identify a VRF by its route distinguisher.
+ {% endblocktrans %}
- {% blocktrans with count=table.rows|length %} + {% blocktrans trimmed with count=table.rows|length %} Warning: The following operation will remove {{ count }} {{ obj_type_plural }} from {{ parent_obj }}. {% endblocktrans %}
- {% blocktrans %} + {% blocktrans trimmed %} Please carefully review the {{ obj_type_plural }} to be removed and confirm below. {% endblocktrans %}
@@ -35,7 +35,7 @@ {% endfor %}- {% blocktrans %} + {% blocktrans trimmed %} Are you sure you want to delete {{ object_type }} {{ object }}? {% endblocktrans %}
diff --git a/netbox/templates/inc/missing_prerequisites.html b/netbox/templates/inc/missing_prerequisites.html index c640004d6..00b6591c8 100644 --- a/netbox/templates/inc/missing_prerequisites.html +++ b/netbox/templates/inc/missing_prerequisites.html @@ -4,7 +4,7 @@{% trans "Check the following" %}:
manage.py collectstatic
was run during the most recent upgrade. This installs the most
recent iteration of each static file into the static root path.
{% endblocktrans %}
STATIC_ROOT
path. Refer to the installation documentation for further guidance.
{% endblocktrans %}
@@ -44,7 +44,7 @@
{{ filename }}
exists in the static root directory and is readable by the HTTP
server.
{% endblocktrans %}
@@ -52,7 +52,7 @@
{% url 'home' as home_url %} - {% blocktrans %} + {% blocktrans trimmed %} Click here to attempt loading NetBox again. {% endblocktrans %}
diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 0116bbdff..38d3655d3 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -6,7 +6,7 @@ {% render_errors form %} {% block title %} - {% blocktrans %} + {% blocktrans trimmed %} Add Device to Cluster {{ cluster }} {% endblocktrans %} {% endblock %} diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 39c86d80e..71a4961c3 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView from circuits.models import Circuit from dcim.models import Device, Rack, Site from ipam.models import IPAddress, Prefix, VLAN, VRF -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from tenancy import filtersets from tenancy.models import * from utilities.utils import count_related @@ -23,7 +23,7 @@ class TenancyRootView(APIRootView): # Tenants # -class TenantGroupViewSet(NetBoxModelViewSet): +class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet): # Contacts # -class ContactGroupViewSet(NetBoxModelViewSet): +class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 72c030d84..5b1051c68 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -1,10 +1,11 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from extras.forms.mixins import TagsMixin from extras.models import Tag from netbox.forms import NetBoxModelForm from tenancy.models import * -from utilities.forms import BootstrapMixin +from utilities.forms.mixins import BootstrapMixin from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField __all__ = ( @@ -121,7 +122,7 @@ class ContactForm(NetBoxModelForm): } -class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): +class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm): group = DynamicModelChoiceField( label=_('Group'), queryset=ContactGroup.objects.all(), @@ -141,11 +142,6 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): label=_('Role'), queryset=ContactRole.objects.all() ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False, - label=_('Tags') - ) class Meta: model = ContactAssignment diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 76a86146c..55193a9a7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -386,7 +386,11 @@ class ContactAssignmentListView(generic.ObjectListView): filterset = filtersets.ContactAssignmentFilterSet filterset_form = forms.ContactAssignmentFilterForm table = tables.ContactAssignmentTable - actions = ('export', 'bulk_edit', 'bulk_delete') + actions = { + 'export': {'view'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + } @register_model_view(ContactAssignment, 'edit') diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po new file mode 100644 index 000000000..b04e843f2 --- /dev/null +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -0,0 +1,12322 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR{module}
will be replaced with the position of the "
+"assigned module, if any."
+msgstr ""
+
+#: dcim/forms/object_create.py:375 dcim/tables/devices.py:1013
+#: ipam/tables/fhrp.py:31 templates/dcim/virtualchassis.html:54
+#: templates/dcim/virtualchassis_edit.html:48 templates/ipam/fhrpgroup.html:39
+msgid "Members"
+msgstr ""
+
+#: dcim/forms/object_create.py:384
+msgid "Initial position"
+msgstr ""
+
+#: dcim/forms/object_create.py:387
+msgid ""
+"Position of the first member device. Increases by one for each additional "
+"member."
+msgstr ""
+
+#: dcim/forms/object_create.py:401
+msgid "A position must be specified for the first VC member."
+msgstr ""
+
+#: dcim/models/cables.py:63 dcim/models/device_component_templates.py:56
+#: dcim/models/device_components.py:64 extras/models/customfields.py:102
+msgid "label"
+msgstr ""
+
+#: dcim/models/cables.py:72
+msgid "length"
+msgstr ""
+
+#: dcim/models/cables.py:79
+msgid "length unit"
+msgstr ""
+
+#: dcim/models/cables.py:94
+msgid "cable"
+msgstr ""
+
+#: dcim/models/cables.py:95
+msgid "cables"
+msgstr ""
+
+#: dcim/models/cables.py:247 ipam/models/asns.py:37
+msgid "end"
+msgstr ""
+
+#: dcim/models/cables.py:297
+msgid "cable termination"
+msgstr ""
+
+#: dcim/models/cables.py:298
+msgid "cable terminations"
+msgstr ""
+
+#: dcim/models/cables.py:421 extras/models/configs.py:50
+msgid "is active"
+msgstr ""
+
+#: dcim/models/cables.py:425
+msgid "is complete"
+msgstr ""
+
+#: dcim/models/cables.py:429
+msgid "is split"
+msgstr ""
+
+#: dcim/models/cables.py:435
+msgid "cable path"
+msgstr ""
+
+#: dcim/models/cables.py:436
+msgid "cable paths"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:47
+#, python-brace-format
+msgid ""
+"{module} is accepted as a substitution for the module bay position when "
+"attached to a module type."
+msgstr ""
+
+#: dcim/models/device_component_templates.py:59
+#: dcim/models/device_components.py:67
+msgid "Physical label"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:104
+msgid "Component templates cannot be moved to a different device type."
+msgstr ""
+
+#: dcim/models/device_component_templates.py:155
+msgid ""
+"A component template cannot be associated with both a device type and a "
+"module type."
+msgstr ""
+
+#: dcim/models/device_component_templates.py:159
+msgid ""
+"A component template must be associated with either a device type or a "
+"module type."
+msgstr ""
+
+#: dcim/models/device_component_templates.py:187
+msgid "console port template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:188
+msgid "console port templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:221
+msgid "console server port template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:222
+msgid "console server port templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:253
+#: dcim/models/device_components.py:354
+msgid "maximum draw"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:260
+#: dcim/models/device_components.py:361
+msgid "allocated draw"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:270
+msgid "power port template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:271
+msgid "power port templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:290
+#: dcim/models/device_components.py:384
+#, python-brace-format
+msgid "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
+msgstr ""
+
+#: dcim/models/device_component_templates.py:322
+#: dcim/models/device_components.py:479
+msgid "feed leg"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:326
+#: dcim/models/device_components.py:483
+msgid "Phase (for three-phase feeds)"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:332
+msgid "power outlet template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:333
+msgid "power outlet templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:342
+#, python-brace-format
+msgid "Parent power port ({power_port}) must belong to the same device type"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:346
+#, python-brace-format
+msgid "Parent power port ({power_port}) must belong to the same module type"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:398
+#: dcim/models/device_components.py:609
+msgid "management only"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:406
+#: dcim/models/device_components.py:552
+msgid "bridge interface"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:424
+#: dcim/models/device_components.py:634
+msgid "wireless role"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:430
+msgid "interface template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:431
+msgid "interface templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:438
+#: dcim/models/device_components.py:796
+#: virtualization/models/virtualmachines.py:340
+msgid "An interface cannot be bridged to itself."
+msgstr ""
+
+#: dcim/models/device_component_templates.py:441
+#, python-brace-format
+msgid "Bridge interface ({bridge}) must belong to the same device type"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:445
+#, python-brace-format
+msgid "Bridge interface ({bridge}) must belong to the same module type"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:501
+#: dcim/models/device_components.py:976
+msgid "rear port position"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:526
+msgid "front port template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:527
+msgid "front port templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:537
+#, python-brace-format
+msgid "Rear port ({name}) must belong to the same device type"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:543
+#, python-brace-format
+msgid ""
+"Invalid rear port position ({position}); rear port {name} has only {count} "
+"positions"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:596
+#: dcim/models/device_components.py:1045
+msgid "positions"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:607
+msgid "rear port template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:608
+msgid "rear port templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:637
+#: dcim/models/device_components.py:1086
+msgid "position"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:640
+#: dcim/models/device_components.py:1089
+msgid "Identifier to reference when renaming installed components"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:646
+msgid "module bay template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:647
+msgid "module bay templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:674
+msgid "device bay template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:675
+msgid "device bay templates"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:688
+#, python-brace-format
+msgid ""
+"Subdevice role of device type ({device_type}) must be set to \"parent\" to "
+"allow device bays."
+msgstr ""
+
+#: dcim/models/device_component_templates.py:743
+#: dcim/models/device_components.py:1215
+msgid "part ID"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:745
+#: dcim/models/device_components.py:1217
+msgid "Manufacturer-assigned part identifier"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:759
+msgid "inventory item template"
+msgstr ""
+
+#: dcim/models/device_component_templates.py:760
+msgid "inventory item templates"
+msgstr ""
+
+#: dcim/models/device_components.py:107
+msgid "Components cannot be moved to a different device."
+msgstr ""
+
+#: dcim/models/device_components.py:146
+msgid "cable end"
+msgstr ""
+
+#: dcim/models/device_components.py:152
+msgid "mark connected"
+msgstr ""
+
+#: dcim/models/device_components.py:154
+msgid "Treat as if a cable is connected"
+msgstr ""
+
+#: dcim/models/device_components.py:172
+msgid "Must specify cable end (A or B) when attaching a cable."
+msgstr ""
+
+#: dcim/models/device_components.py:176
+msgid "Cable end must not be set without a cable."
+msgstr ""
+
+#: dcim/models/device_components.py:180
+msgid "Cannot mark as connected with a cable attached."
+msgstr ""
+
+#: dcim/models/device_components.py:204
+#, python-brace-format
+msgid "{class_name} models must declare a parent_object property"
+msgstr ""
+
+#: dcim/models/device_components.py:289 dcim/models/device_components.py:318
+#: dcim/models/device_components.py:351 dcim/models/device_components.py:469
+msgid "Physical port type"
+msgstr ""
+
+#: dcim/models/device_components.py:292 dcim/models/device_components.py:321
+msgid "speed"
+msgstr ""
+
+#: dcim/models/device_components.py:296 dcim/models/device_components.py:325
+msgid "Port speed in bits per second"
+msgstr ""
+
+#: dcim/models/device_components.py:302
+msgid "console port"
+msgstr ""
+
+#: dcim/models/device_components.py:303
+msgid "console ports"
+msgstr ""
+
+#: dcim/models/device_components.py:331
+msgid "console server port"
+msgstr ""
+
+#: dcim/models/device_components.py:332
+msgid "console server ports"
+msgstr ""
+
+#: dcim/models/device_components.py:371
+msgid "power port"
+msgstr ""
+
+#: dcim/models/device_components.py:372
+msgid "power ports"
+msgstr ""
+
+#: dcim/models/device_components.py:489
+msgid "power outlet"
+msgstr ""
+
+#: dcim/models/device_components.py:490
+msgid "power outlets"
+msgstr ""
+
+#: dcim/models/device_components.py:501
+#, python-brace-format
+msgid "Parent power port ({power_port}) must belong to the same device"
+msgstr ""
+
+#: dcim/models/device_components.py:532
+msgid "mode"
+msgstr ""
+
+#: dcim/models/device_components.py:536
+msgid "IEEE 802.1Q tagging strategy"
+msgstr ""
+
+#: dcim/models/device_components.py:544
+msgid "parent interface"
+msgstr ""
+
+#: dcim/models/device_components.py:600
+msgid "parent LAG"
+msgstr ""
+
+#: dcim/models/device_components.py:610
+msgid "This interface is used only for out-of-band management"
+msgstr ""
+
+#: dcim/models/device_components.py:615
+msgid "speed (Kbps)"
+msgstr ""
+
+#: dcim/models/device_components.py:618
+msgid "duplex"
+msgstr ""
+
+#: dcim/models/device_components.py:628
+msgid "64-bit World Wide Name"
+msgstr ""
+
+#: dcim/models/device_components.py:640
+msgid "wireless channel"
+msgstr ""
+
+#: dcim/models/device_components.py:647
+msgid "channel frequency (MHz)"
+msgstr ""
+
+#: dcim/models/device_components.py:648 dcim/models/device_components.py:656
+msgid "Populated by selected channel (if set)"
+msgstr ""
+
+#: dcim/models/device_components.py:662
+msgid "transmit power (dBm)"
+msgstr ""
+
+#: dcim/models/device_components.py:687 wireless/models.py:116
+msgid "wireless LANs"
+msgstr ""
+
+#: dcim/models/device_components.py:695
+#: virtualization/models/virtualmachines.py:266
+msgid "untagged VLAN"
+msgstr ""
+
+#: dcim/models/device_components.py:701
+#: virtualization/models/virtualmachines.py:272
+msgid "tagged VLANs"
+msgstr ""
+
+#: dcim/models/device_components.py:737
+#: virtualization/models/virtualmachines.py:309
+msgid "interface"
+msgstr ""
+
+#: dcim/models/device_components.py:738
+#: virtualization/models/virtualmachines.py:310
+msgid "interfaces"
+msgstr ""
+
+#: dcim/models/device_components.py:749
+#, python-brace-format
+msgid "{display_type} interfaces cannot have a cable attached."
+msgstr ""
+
+#: dcim/models/device_components.py:757
+#, python-brace-format
+msgid "{display_type} interfaces cannot be marked as connected."
+msgstr ""
+
+#: dcim/models/device_components.py:766
+#: virtualization/models/virtualmachines.py:325
+msgid "An interface cannot be its own parent."
+msgstr ""
+
+#: dcim/models/device_components.py:770
+msgid "Only virtual interfaces may be assigned to a parent interface."
+msgstr ""
+
+#: dcim/models/device_components.py:777
+#, python-brace-format
+msgid ""
+"The selected parent interface ({interface}) belongs to a different device "
+"({device})"
+msgstr ""
+
+#: dcim/models/device_components.py:783
+#, python-brace-format
+msgid ""
+"The selected parent interface ({interface}) belongs to {device}, which is "
+"not part of virtual chassis {virtual_chassis}."
+msgstr ""
+
+#: dcim/models/device_components.py:803
+#, python-brace-format
+msgid ""
+"The selected bridge interface ({bridge}) belongs to a different device "
+"({device})."
+msgstr ""
+
+#: dcim/models/device_components.py:809
+#, python-brace-format
+msgid ""
+"The selected bridge interface ({interface}) belongs to {device}, which is "
+"not part of virtual chassis {virtual_chassis}."
+msgstr ""
+
+#: dcim/models/device_components.py:820
+msgid "Virtual interfaces cannot have a parent LAG interface."
+msgstr ""
+
+#: dcim/models/device_components.py:824
+msgid "A LAG interface cannot be its own parent."
+msgstr ""
+
+#: dcim/models/device_components.py:831
+#, python-brace-format
+msgid ""
+"The selected LAG interface ({lag}) belongs to a different device ({device})."
+msgstr ""
+
+#: dcim/models/device_components.py:837
+#, python-brace-format
+msgid ""
+"The selected LAG interface ({lag}) belongs to {device}, which is not part of "
+"virtual chassis {virtual_chassis}."
+msgstr ""
+
+#: dcim/models/device_components.py:848
+msgid "Virtual interfaces cannot have a PoE mode."
+msgstr ""
+
+#: dcim/models/device_components.py:852
+msgid "Virtual interfaces cannot have a PoE type."
+msgstr ""
+
+#: dcim/models/device_components.py:858
+msgid "Must specify PoE mode when designating a PoE type."
+msgstr ""
+
+#: dcim/models/device_components.py:865
+msgid "Wireless role may be set only on wireless interfaces."
+msgstr ""
+
+#: dcim/models/device_components.py:867
+msgid "Channel may be set only on wireless interfaces."
+msgstr ""
+
+#: dcim/models/device_components.py:873
+msgid "Channel frequency may be set only on wireless interfaces."
+msgstr ""
+
+#: dcim/models/device_components.py:877
+msgid "Cannot specify custom frequency with channel selected."
+msgstr ""
+
+#: dcim/models/device_components.py:883
+msgid "Channel width may be set only on wireless interfaces."
+msgstr ""
+
+#: dcim/models/device_components.py:885
+msgid "Cannot specify custom width with channel selected."
+msgstr ""
+
+#: dcim/models/device_components.py:893
+#, python-brace-format
+msgid ""
+"The untagged VLAN ({untagged_vlan}) must belong to the same site as the "
+"interface's parent device, or it must be global."
+msgstr ""
+
+#: dcim/models/device_components.py:982
+msgid "Mapped position on corresponding rear port"
+msgstr ""
+
+#: dcim/models/device_components.py:998
+msgid "front port"
+msgstr ""
+
+#: dcim/models/device_components.py:999
+msgid "front ports"
+msgstr ""
+
+#: dcim/models/device_components.py:1013
+#, python-brace-format
+msgid "Rear port ({rear_port}) must belong to the same device"
+msgstr ""
+
+#: dcim/models/device_components.py:1021
+#, python-brace-format
+msgid ""
+"Invalid rear port position ({rear_port_position}): Rear port {name} has only "
+"{positions} positions."
+msgstr ""
+
+#: dcim/models/device_components.py:1051
+msgid "Number of front ports which may be mapped"
+msgstr ""
+
+#: dcim/models/device_components.py:1056
+msgid "rear port"
+msgstr ""
+
+#: dcim/models/device_components.py:1057
+msgid "rear ports"
+msgstr ""
+
+#: dcim/models/device_components.py:1071
+#, python-brace-format
+msgid ""
+"The number of positions cannot be less than the number of mapped front ports "
+"({frontport_count})"
+msgstr ""
+
+#: dcim/models/device_components.py:1095
+msgid "module bay"
+msgstr ""
+
+#: dcim/models/device_components.py:1096
+msgid "module bays"
+msgstr ""
+
+#: dcim/models/device_components.py:1109
+msgid "parent_bay"
+msgstr ""
+
+#: dcim/models/device_components.py:1117
+msgid "device bay"
+msgstr ""
+
+#: dcim/models/device_components.py:1118
+msgid "device bays"
+msgstr ""
+
+#: dcim/models/device_components.py:1128
+#, python-brace-format
+msgid "This type of device ({device_type}) does not support device bays."
+msgstr ""
+
+#: dcim/models/device_components.py:1134
+msgid "Cannot install a device into itself."
+msgstr ""
+
+#: dcim/models/device_components.py:1142
+#, python-brace-format
+msgid ""
+"Cannot install the specified device; device is already installed in {bay}."
+msgstr ""
+
+#: dcim/models/device_components.py:1163
+msgid "inventory item role"
+msgstr ""
+
+#: dcim/models/device_components.py:1164
+msgid "inventory item roles"
+msgstr ""
+
+#: dcim/models/device_components.py:1221 dcim/models/devices.py:595
+#: dcim/models/devices.py:1168 dcim/models/racks.py:113
+msgid "serial number"
+msgstr ""
+
+#: dcim/models/device_components.py:1229 dcim/models/devices.py:603
+#: dcim/models/devices.py:1175 dcim/models/racks.py:120
+msgid "asset tag"
+msgstr ""
+
+#: dcim/models/device_components.py:1230
+msgid "A unique tag used to identify this item"
+msgstr ""
+
+#: dcim/models/device_components.py:1233
+msgid "discovered"
+msgstr ""
+
+#: dcim/models/device_components.py:1235
+msgid "This item was automatically discovered"
+msgstr ""
+
+#: dcim/models/device_components.py:1250
+msgid "inventory item"
+msgstr ""
+
+#: dcim/models/device_components.py:1251
+msgid "inventory items"
+msgstr ""
+
+#: dcim/models/device_components.py:1262
+msgid "Cannot assign self as parent."
+msgstr ""
+
+#: dcim/models/device_components.py:1270
+msgid "Parent inventory item does not belong to the same device."
+msgstr ""
+
+#: dcim/models/device_components.py:1276
+msgid "Cannot move an inventory item with dependent children"
+msgstr ""
+
+#: dcim/models/device_components.py:1284
+msgid "Cannot assign inventory item to component on another device"
+msgstr ""
+
+#: dcim/models/devices.py:54
+msgid "manufacturer"
+msgstr ""
+
+#: dcim/models/devices.py:55
+msgid "manufacturers"
+msgstr ""
+
+#: dcim/models/devices.py:82 dcim/models/devices.py:381
+msgid "model"
+msgstr ""
+
+#: dcim/models/devices.py:95
+msgid "default platform"
+msgstr ""
+
+#: dcim/models/devices.py:98 dcim/models/devices.py:385
+msgid "part number"
+msgstr ""
+
+#: dcim/models/devices.py:101 dcim/models/devices.py:388
+msgid "Discrete part number (optional)"
+msgstr ""
+
+#: dcim/models/devices.py:107 dcim/models/racks.py:137
+msgid "height (U)"
+msgstr ""
+
+#: dcim/models/devices.py:111
+msgid "exclude from utilization"
+msgstr ""
+
+#: dcim/models/devices.py:112
+msgid "Exclude from rack utilization calculations."
+msgstr ""
+
+#: dcim/models/devices.py:116
+msgid "is full depth"
+msgstr ""
+
+#: dcim/models/devices.py:117
+msgid "Device consumes both front and rear rack faces"
+msgstr ""
+
+#: dcim/models/devices.py:123
+msgid "parent/child status"
+msgstr ""
+
+#: dcim/models/devices.py:124
+msgid ""
+"Parent devices house child devices in device bays. Leave blank if this "
+"device type is neither a parent nor a child."
+msgstr ""
+
+#: dcim/models/devices.py:128 dcim/models/devices.py:647
+msgid "airflow"
+msgstr ""
+
+#: dcim/models/devices.py:204
+msgid "device type"
+msgstr ""
+
+#: dcim/models/devices.py:205
+msgid "device types"
+msgstr ""
+
+#: dcim/models/devices.py:289
+msgid "U height must be in increments of 0.5 rack units."
+msgstr ""
+
+#: dcim/models/devices.py:306
+#, python-brace-format
+msgid ""
+"Device {device} in rack {rack} does not have sufficient space to accommodate "
+"a height of {height}U"
+msgstr ""
+
+#: dcim/models/devices.py:321
+#, python-brace-format
+msgid ""
+"Unable to set 0U height: Found {racked_instance_count} "
+"instances already mounted within racks."
+msgstr ""
+
+#: dcim/models/devices.py:330
+msgid ""
+"Must delete all device bay templates associated with this device before "
+"declassifying it as a parent device."
+msgstr ""
+
+#: dcim/models/devices.py:336
+msgid "Child device types must be 0U."
+msgstr ""
+
+#: dcim/models/devices.py:404
+msgid "module type"
+msgstr ""
+
+#: dcim/models/devices.py:405
+msgid "module types"
+msgstr ""
+
+#: dcim/models/devices.py:473
+msgid "Virtual machines may be assigned to this role"
+msgstr ""
+
+#: dcim/models/devices.py:485
+msgid "device role"
+msgstr ""
+
+#: dcim/models/devices.py:486
+msgid "device roles"
+msgstr ""
+
+#: dcim/models/devices.py:503
+msgid "Optionally limit this platform to devices of a certain manufacturer"
+msgstr ""
+
+#: dcim/models/devices.py:515
+msgid "platform"
+msgstr ""
+
+#: dcim/models/devices.py:516
+msgid "platforms"
+msgstr ""
+
+#: dcim/models/devices.py:564
+msgid "The function this device serves"
+msgstr ""
+
+#: dcim/models/devices.py:596
+msgid "Chassis serial number, assigned by the manufacturer"
+msgstr ""
+
+#: dcim/models/devices.py:604 dcim/models/devices.py:1176
+msgid "A unique tag used to identify this device"
+msgstr ""
+
+#: dcim/models/devices.py:631
+msgid "position (U)"
+msgstr ""
+
+#: dcim/models/devices.py:638
+msgid "rack face"
+msgstr ""
+
+#: dcim/models/devices.py:658 dcim/models/devices.py:1385
+#: virtualization/models/virtualmachines.py:97
+msgid "primary IPv4"
+msgstr ""
+
+#: dcim/models/devices.py:666 dcim/models/devices.py:1393
+#: virtualization/models/virtualmachines.py:105
+msgid "primary IPv6"
+msgstr ""
+
+#: dcim/models/devices.py:674
+msgid "out-of-band IP"
+msgstr ""
+
+#: dcim/models/devices.py:691
+msgid "VC position"
+msgstr ""
+
+#: dcim/models/devices.py:695
+msgid "Virtual chassis position"
+msgstr ""
+
+#: dcim/models/devices.py:698
+msgid "VC priority"
+msgstr ""
+
+#: dcim/models/devices.py:702
+msgid "Virtual chassis master election priority"
+msgstr ""
+
+#: dcim/models/devices.py:705 dcim/models/sites.py:207
+msgid "latitude"
+msgstr ""
+
+#: dcim/models/devices.py:710 dcim/models/devices.py:718
+#: dcim/models/sites.py:212 dcim/models/sites.py:220
+msgid "GPS coordinate in decimal format (xx.yyyyyy)"
+msgstr ""
+
+#: dcim/models/devices.py:713 dcim/models/sites.py:215
+msgid "longitude"
+msgstr ""
+
+#: dcim/models/devices.py:786
+msgid "Device name must be unique per site."
+msgstr ""
+
+#: dcim/models/devices.py:797 ipam/models/services.py:75
+msgid "device"
+msgstr ""
+
+#: dcim/models/devices.py:798
+msgid "devices"
+msgstr ""
+
+#: dcim/models/devices.py:838
+#, python-brace-format
+msgid "Rack {rack} does not belong to site {site}."
+msgstr ""
+
+#: dcim/models/devices.py:843
+#, python-brace-format
+msgid "Location {location} does not belong to site {site}."
+msgstr ""
+
+#: dcim/models/devices.py:849
+#, python-brace-format
+msgid "Rack {rack} does not belong to location {location}."
+msgstr ""
+
+#: dcim/models/devices.py:856
+msgid "Cannot select a rack face without assigning a rack."
+msgstr ""
+
+#: dcim/models/devices.py:860
+msgid "Cannot select a rack position without assigning a rack."
+msgstr ""
+
+#: dcim/models/devices.py:866
+msgid "Position must be in increments of 0.5 rack units."
+msgstr ""
+
+#: dcim/models/devices.py:870
+msgid "Must specify rack face when defining rack position."
+msgstr ""
+
+#: dcim/models/devices.py:878
+#, python-brace-format
+msgid "A U0 device type ({device_type}) cannot be assigned to a rack position."
+msgstr ""
+
+#: dcim/models/devices.py:889
+msgid ""
+"Child device types cannot be assigned to a rack face. This is an attribute "
+"of the parent device."
+msgstr ""
+
+#: dcim/models/devices.py:896
+msgid ""
+"Child device types cannot be assigned to a rack position. This is an "
+"attribute of the parent device."
+msgstr ""
+
+#: dcim/models/devices.py:910
+#, python-brace-format
+msgid ""
+"U{position} is already occupied or does not have sufficient space to "
+"accommodate this device type: {device_type} ({u_height}U)"
+msgstr ""
+
+#: dcim/models/devices.py:925
+#, python-brace-format
+msgid "{ip} is not an IPv4 address."
+msgstr ""
+
+#: dcim/models/devices.py:934 dcim/models/devices.py:949
+#, python-brace-format
+msgid "The specified IP address ({ip}) is not assigned to this device."
+msgstr ""
+
+#: dcim/models/devices.py:940
+#, python-brace-format
+msgid "{ip} is not an IPv6 address."
+msgstr ""
+
+#: dcim/models/devices.py:967
+#, python-brace-format
+msgid ""
+"The assigned platform is limited to {platform_manufacturer} device types, "
+"but this device's type belongs to {devicetype_manufacturer}."
+msgstr ""
+
+#: dcim/models/devices.py:978
+#, python-brace-format
+msgid "The assigned cluster belongs to a different site ({site})"
+msgstr ""
+
+#: dcim/models/devices.py:986
+msgid "A device assigned to a virtual chassis must have its position defined."
+msgstr ""
+
+#: dcim/models/devices.py:1183
+msgid "module"
+msgstr ""
+
+#: dcim/models/devices.py:1184
+msgid "modules"
+msgstr ""
+
+#: dcim/models/devices.py:1200
+#, python-brace-format
+msgid ""
+"Module must be installed within a module bay belonging to the assigned "
+"device ({device})."
+msgstr ""
+
+#: dcim/models/devices.py:1304
+msgid "domain"
+msgstr ""
+
+#: dcim/models/devices.py:1317 dcim/models/devices.py:1318
+msgid "virtual chassis"
+msgstr ""
+
+#: dcim/models/devices.py:1333
+#, python-brace-format
+msgid "The selected master ({master}) is not assigned to this virtual chassis."
+msgstr ""
+
+#: dcim/models/devices.py:1349
+#, python-brace-format
+msgid ""
+"Unable to delete virtual chassis {self}. There are member interfaces which "
+"form a cross-chassis LAG interfaces."
+msgstr ""
+
+#: dcim/models/devices.py:1374 ipam/models/l2vpn.py:37
+msgid "identifier"
+msgstr ""
+
+#: dcim/models/devices.py:1375
+msgid "Numeric identifier unique to the parent device"
+msgstr ""
+
+#: dcim/models/devices.py:1403 extras/models/models.py:629
+#: netbox/models/__init__.py:114
+msgid "comments"
+msgstr ""
+
+#: dcim/models/devices.py:1419
+msgid "virtual device context"
+msgstr ""
+
+#: dcim/models/devices.py:1420
+msgid "virtual device contexts"
+msgstr ""
+
+#: dcim/models/devices.py:1452
+#, python-brace-format
+msgid "{ip} is not an IPv{family} address."
+msgstr ""
+
+#: dcim/models/devices.py:1458
+msgid "Primary IP address must belong to an interface on the assigned device."
+msgstr ""
+
+#: dcim/models/mixins.py:15 extras/models/configs.py:41
+#: extras/models/models.py:260 extras/models/models.py:469
+#: extras/models/search.py:48 ipam/models/ip.py:193
+msgid "weight"
+msgstr ""
+
+#: dcim/models/mixins.py:22
+msgid "weight unit"
+msgstr ""
+
+#: dcim/models/mixins.py:51
+msgid "Must specify a unit when setting a weight"
+msgstr ""
+
+#: dcim/models/power.py:55
+msgid "power panel"
+msgstr ""
+
+#: dcim/models/power.py:56
+msgid "power panels"
+msgstr ""
+
+#: dcim/models/power.py:70
+#, python-brace-format
+msgid ""
+"Location {location} ({location_site}) is in a different site than {site}"
+msgstr ""
+
+#: dcim/models/power.py:107
+msgid "supply"
+msgstr ""
+
+#: dcim/models/power.py:113
+msgid "phase"
+msgstr ""
+
+#: dcim/models/power.py:119
+msgid "voltage"
+msgstr ""
+
+#: dcim/models/power.py:124
+msgid "amperage"
+msgstr ""
+
+#: dcim/models/power.py:129
+msgid "max utilization"
+msgstr ""
+
+#: dcim/models/power.py:132
+msgid "Maximum permissible draw (percentage)"
+msgstr ""
+
+#: dcim/models/power.py:135
+msgid "available power"
+msgstr ""
+
+#: dcim/models/power.py:163
+msgid "power feed"
+msgstr ""
+
+#: dcim/models/power.py:164
+msgid "power feeds"
+msgstr ""
+
+#: dcim/models/power.py:178
+#, python-brace-format
+msgid ""
+"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in "
+"different sites"
+msgstr ""
+
+#: dcim/models/power.py:189
+msgid "Voltage cannot be negative for AC supply"
+msgstr ""
+
+#: dcim/models/racks.py:49
+msgid "rack role"
+msgstr ""
+
+#: dcim/models/racks.py:50
+msgid "rack roles"
+msgstr ""
+
+#: dcim/models/racks.py:74
+msgid "facility ID"
+msgstr ""
+
+#: dcim/models/racks.py:75
+msgid "Locally-assigned identifier"
+msgstr ""
+
+#: dcim/models/racks.py:108 ipam/forms/bulk_import.py:203
+#: ipam/forms/bulk_import.py:268 ipam/forms/bulk_import.py:303
+#: ipam/forms/bulk_import.py:470 virtualization/forms/bulk_import.py:111
+msgid "Functional role"
+msgstr ""
+
+#: dcim/models/racks.py:121
+msgid "A unique tag used to identify this rack"
+msgstr ""
+
+#: dcim/models/racks.py:132
+msgid "width"
+msgstr ""
+
+#: dcim/models/racks.py:133
+msgid "Rail-to-rail width"
+msgstr ""
+
+#: dcim/models/racks.py:139
+msgid "Height in rack units"
+msgstr ""
+
+#: dcim/models/racks.py:143
+msgid "starting unit"
+msgstr ""
+
+#: dcim/models/racks.py:144
+msgid "Starting unit for rack"
+msgstr ""
+
+#: dcim/models/racks.py:148
+msgid "descending units"
+msgstr ""
+
+#: dcim/models/racks.py:149
+msgid "Units are numbered top-to-bottom"
+msgstr ""
+
+#: dcim/models/racks.py:152
+msgid "outer width"
+msgstr ""
+
+#: dcim/models/racks.py:155
+msgid "Outer dimension of rack (width)"
+msgstr ""
+
+#: dcim/models/racks.py:158
+msgid "outer depth"
+msgstr ""
+
+#: dcim/models/racks.py:161
+msgid "Outer dimension of rack (depth)"
+msgstr ""
+
+#: dcim/models/racks.py:164
+msgid "outer unit"
+msgstr ""
+
+#: dcim/models/racks.py:170
+msgid "max weight"
+msgstr ""
+
+#: dcim/models/racks.py:173
+msgid "Maximum load capacity for the rack"
+msgstr ""
+
+#: dcim/models/racks.py:181
+msgid "mounting depth"
+msgstr ""
+
+#: dcim/models/racks.py:185
+msgid ""
+"Maximum depth of a mounted device, in millimeters. For four-post racks, this "
+"is the distance between the front and rear rails."
+msgstr ""
+
+#: dcim/models/racks.py:219
+msgid "rack"
+msgstr ""
+
+#: dcim/models/racks.py:220
+msgid "racks"
+msgstr ""
+
+#: dcim/models/racks.py:235
+#, python-brace-format
+msgid "Assigned location must belong to parent site ({site})."
+msgstr ""
+
+#: dcim/models/racks.py:239
+msgid "Must specify a unit when setting an outer width/depth"
+msgstr ""
+
+#: dcim/models/racks.py:243
+msgid "Must specify a unit when setting a maximum weight"
+msgstr ""
+
+#: dcim/models/racks.py:253
+#, python-brace-format
+msgid ""
+"Rack must be at least {min_height}U tall to house currently installed "
+"devices."
+msgstr ""
+
+#: dcim/models/racks.py:260
+#, python-brace-format
+msgid ""
+"Rack unit numbering must begin at {position} or less to house currently "
+"installed devices."
+msgstr ""
+
+#: dcim/models/racks.py:268
+#, python-brace-format
+msgid "Location must be from the same site, {site}."
+msgstr ""
+
+#: dcim/models/racks.py:521
+msgid "units"
+msgstr ""
+
+#: dcim/models/racks.py:547
+msgid "rack reservation"
+msgstr ""
+
+#: dcim/models/racks.py:548
+msgid "rack reservations"
+msgstr ""
+
+#: dcim/models/racks.py:565
+#, python-brace-format
+msgid "Invalid unit(s) for {height}U rack: {unit_list}"
+msgstr ""
+
+#: dcim/models/racks.py:578
+#, python-brace-format
+msgid "The following units have already been reserved: {unit_list}"
+msgstr ""
+
+#: dcim/models/sites.py:49
+msgid "A top-level region with this name already exists."
+msgstr ""
+
+#: dcim/models/sites.py:59
+msgid "A top-level region with this slug already exists."
+msgstr ""
+
+#: dcim/models/sites.py:62
+msgid "region"
+msgstr ""
+
+#: dcim/models/sites.py:63
+msgid "regions"
+msgstr ""
+
+#: dcim/models/sites.py:102
+msgid "A top-level site group with this name already exists."
+msgstr ""
+
+#: dcim/models/sites.py:112
+msgid "A top-level site group with this slug already exists."
+msgstr ""
+
+#: dcim/models/sites.py:115
+msgid "site group"
+msgstr ""
+
+#: dcim/models/sites.py:116
+msgid "site groups"
+msgstr ""
+
+#: dcim/models/sites.py:141
+msgid "Full name of the site"
+msgstr ""
+
+#: dcim/models/sites.py:181
+msgid "facility"
+msgstr ""
+
+#: dcim/models/sites.py:184
+msgid "Local facility ID or description"
+msgstr ""
+
+#: dcim/models/sites.py:195
+msgid "physical address"
+msgstr ""
+
+#: dcim/models/sites.py:198
+msgid "Physical location of the building"
+msgstr ""
+
+#: dcim/models/sites.py:201
+msgid "shipping address"
+msgstr ""
+
+#: dcim/models/sites.py:204
+msgid "If different from the physical address"
+msgstr ""
+
+#: dcim/models/sites.py:238
+msgid "site"
+msgstr ""
+
+#: dcim/models/sites.py:239
+msgid "sites"
+msgstr ""
+
+#: dcim/models/sites.py:303
+msgid "A location with this name already exists within the specified site."
+msgstr ""
+
+#: dcim/models/sites.py:313
+msgid "A location with this slug already exists within the specified site."
+msgstr ""
+
+#: dcim/models/sites.py:316
+msgid "location"
+msgstr ""
+
+#: dcim/models/sites.py:317
+msgid "locations"
+msgstr ""
+
+#: dcim/models/sites.py:331
+#, python-brace-format
+msgid "Parent location ({parent}) must belong to the same site ({site})."
+msgstr ""
+
+#: dcim/tables/cables.py:54
+msgid "Termination A"
+msgstr ""
+
+#: dcim/tables/cables.py:59
+msgid "Termination B"
+msgstr ""
+
+#: dcim/tables/cables.py:65 wireless/tables/wirelesslink.py:22
+msgid "Device A"
+msgstr ""
+
+#: dcim/tables/cables.py:71 wireless/tables/wirelesslink.py:31
+msgid "Device B"
+msgstr ""
+
+#: dcim/tables/cables.py:77
+msgid "Location A"
+msgstr ""
+
+#: dcim/tables/cables.py:83
+msgid "Location B"
+msgstr ""
+
+#: dcim/tables/cables.py:89
+msgid "Rack A"
+msgstr ""
+
+#: dcim/tables/cables.py:95
+msgid "Rack B"
+msgstr ""
+
+#: dcim/tables/cables.py:101
+msgid "Site A"
+msgstr ""
+
+#: dcim/tables/cables.py:107
+msgid "Site B"
+msgstr ""
+
+#: dcim/tables/connections.py:27 templates/dcim/consoleport.html:18
+#: templates/dcim/consoleserverport.html:75 templates/dcim/frontport.html:119
+#: templates/dcim/inventoryitem_edit.html:39
+msgid "Console Port"
+msgstr ""
+
+#: dcim/tables/connections.py:31 dcim/tables/connections.py:50
+#: dcim/tables/connections.py:71
+#: templates/dcim/inc/connection_endpoints.html:16
+msgid "Reachable"
+msgstr ""
+
+#: dcim/tables/connections.py:46 dcim/tables/devices.py:518
+#: templates/dcim/inventoryitem_edit.html:64 templates/dcim/poweroutlet.html:47
+#: templates/dcim/powerport.html:18
+msgid "Power Port"
+msgstr ""
+
+#: dcim/tables/devices.py:94 dcim/tables/devices.py:139 dcim/tables/racks.py:81
+#: dcim/tables/sites.py:143 netbox/navigation/menu.py:57
+#: netbox/navigation/menu.py:61 netbox/navigation/menu.py:63
+#: virtualization/forms/model_forms.py:124 virtualization/tables/clusters.py:83
+#: virtualization/views.py:211
+msgid "Devices"
+msgstr ""
+
+#: dcim/tables/devices.py:99 dcim/tables/devices.py:144
+#: virtualization/tables/clusters.py:88
+msgid "VMs"
+msgstr ""
+
+#: dcim/tables/devices.py:133 dcim/tables/devices.py:245
+#: extras/forms/model_forms.py:403 templates/dcim/device.html:131
+#: templates/dcim/device/render_config.html:11
+#: templates/dcim/device/render_config.html:15
+#: templates/dcim/devicerole.html:47 templates/dcim/platform.html:44
+#: templates/extras/configtemplate.html:10
+#: templates/virtualization/virtualmachine.html:47
+#: templates/virtualization/virtualmachine/render_config.html:11
+#: templates/virtualization/virtualmachine/render_config.html:15
+#: virtualization/tables/virtualmachines.py:88
+msgid "Config Template"
+msgstr ""
+
+#: dcim/tables/devices.py:216 dcim/tables/devices.py:1048
+#: ipam/forms/model_forms.py:298 ipam/tables/ip.py:352 ipam/tables/ip.py:418
+#: ipam/tables/ip.py:441 templates/ipam/ipaddress.html:12
+#: templates/ipam/ipaddress_edit.html:14
+#: virtualization/tables/virtualmachines.py:79
+msgid "IP Address"
+msgstr ""
+
+#: dcim/tables/devices.py:220 dcim/tables/devices.py:1052
+#: virtualization/tables/virtualmachines.py:70
+msgid "IPv4 Address"
+msgstr ""
+
+#: dcim/tables/devices.py:224 dcim/tables/devices.py:1056
+#: virtualization/tables/virtualmachines.py:74
+msgid "IPv6 Address"
+msgstr ""
+
+#: dcim/tables/devices.py:239
+msgid "VC Position"
+msgstr ""
+
+#: dcim/tables/devices.py:242
+msgid "VC Priority"
+msgstr ""
+
+#: dcim/tables/devices.py:249 templates/dcim/device_edit.html:38
+#: templates/dcim/devicebay_populate.html:16
+msgid "Parent Device"
+msgstr ""
+
+#: dcim/tables/devices.py:254
+msgid "Position (Device Bay)"
+msgstr ""
+
+#: dcim/tables/devices.py:263
+msgid "Console ports"
+msgstr ""
+
+#: dcim/tables/devices.py:266
+msgid "Console server ports"
+msgstr ""
+
+#: dcim/tables/devices.py:269
+msgid "Power ports"
+msgstr ""
+
+#: dcim/tables/devices.py:272
+msgid "Power outlets"
+msgstr ""
+
+#: dcim/tables/devices.py:275 dcim/tables/devices.py:1061
+#: dcim/tables/devicetypes.py:125 dcim/views.py:1002 dcim/views.py:1241
+#: dcim/views.py:1927 netbox/navigation/menu.py:82
+#: netbox/navigation/menu.py:220 templates/dcim/device/base.html:37
+#: templates/dcim/device_list.html:43 templates/dcim/devicetype/base.html:34
+#: templates/dcim/module.html:34 templates/dcim/moduletype/base.html:34
+#: templates/dcim/virtualdevicecontext.html:64
+#: templates/dcim/virtualdevicecontext.html:85
+#: templates/virtualization/virtualmachine_list.html:14
+#: virtualization/tables/virtualmachines.py:85 virtualization/views.py:368
+#: wireless/tables/wirelesslan.py:55
+msgid "Interfaces"
+msgstr ""
+
+#: dcim/tables/devices.py:278
+msgid "Front ports"
+msgstr ""
+
+#: dcim/tables/devices.py:284
+msgid "Device bays"
+msgstr ""
+
+#: dcim/tables/devices.py:287
+msgid "Module bays"
+msgstr ""
+
+#: dcim/tables/devices.py:290
+msgid "Inventory items"
+msgstr ""
+
+#: dcim/tables/devices.py:329 dcim/tables/modules.py:56
+#: templates/dcim/modulebay.html:17
+msgid "Module Bay"
+msgstr ""
+
+#: dcim/tables/devices.py:350
+msgid "Cable Color"
+msgstr ""
+
+#: dcim/tables/devices.py:356
+msgid "Link Peers"
+msgstr ""
+
+#: dcim/tables/devices.py:359
+msgid "Mark Connected"
+msgstr ""
+
+#: dcim/tables/devices.py:567 ipam/forms/model_forms.py:709
+#: ipam/tables/fhrp.py:28 ipam/views.py:599 ipam/views.py:673
+#: netbox/navigation/menu.py:146 netbox/navigation/menu.py:148
+#: templates/dcim/interface.html:347 templates/ipam/ipaddress_bulk_add.html:15
+#: templates/ipam/service.html:43 templates/virtualization/vminterface.html:84
+msgid "IP Addresses"
+msgstr ""
+
+#: dcim/tables/devices.py:573 netbox/navigation/menu.py:190
+#: templates/ipam/inc/panels/fhrp_groups.html:5
+msgid "FHRP Groups"
+msgstr ""
+
+#: dcim/tables/devices.py:604 dcim/tables/devicetypes.py:224
+#: templates/dcim/interface.html:66
+msgid "Management Only"
+msgstr ""
+
+#: dcim/tables/devices.py:612
+msgid "Wireless link"
+msgstr ""
+
+#: dcim/tables/devices.py:622
+msgid "VDCs"
+msgstr ""
+
+#: dcim/tables/devices.py:706
+#: templates/circuits/inc/circuit_termination.html:80
+#: templates/dcim/consoleport.html:81 templates/dcim/consoleserverport.html:81
+#: templates/dcim/frontport.html:53 templates/dcim/frontport.html:125
+#: templates/dcim/interface.html:192 templates/dcim/inventoryitem_edit.html:69
+#: templates/dcim/rearport.html:18 templates/dcim/rearport.html:115
+msgid "Rear Port"
+msgstr ""
+
+#: dcim/tables/devices.py:871 templates/dcim/modulebay.html:51
+msgid "Installed Module"
+msgstr ""
+
+#: dcim/tables/devices.py:874
+msgid "Module Serial"
+msgstr ""
+
+#: dcim/tables/devices.py:878
+msgid "Module Asset Tag"
+msgstr ""
+
+#: dcim/tables/devices.py:887
+msgid "Module Status"
+msgstr ""
+
+#: dcim/tables/devices.py:929 dcim/tables/devicetypes.py:308
+#: templates/dcim/inventoryitem.html:41
+msgid "Component"
+msgstr ""
+
+#: dcim/tables/devices.py:980
+msgid "Items"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:38 netbox/navigation/menu.py:72
+#: netbox/navigation/menu.py:74
+msgid "Device Types"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:43 netbox/navigation/menu.py:75
+msgid "Module Types"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:48 dcim/tables/devicetypes.py:140
+#: dcim/views.py:1077 dcim/views.py:2020 netbox/navigation/menu.py:91
+#: templates/dcim/device/base.html:52 templates/dcim/device_list.html:71
+#: templates/dcim/devicetype/base.html:49
+#: templates/dcim/inc/panels/inventory_items.html:5
+#: templates/dcim/inventoryitemrole.html:33
+msgid "Inventory Items"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:53 extras/forms/filtersets.py:354
+#: extras/forms/model_forms.py:311 netbox/navigation/menu.py:66
+msgid "Platforms"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:85 templates/dcim/devicetype.html:32
+msgid "Default Platform"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:89 templates/dcim/devicetype.html:48
+msgid "Full Depth"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:98
+msgid "U Height"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:110 dcim/tables/modules.py:26
+msgid "Instances"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:113 dcim/views.py:942 dcim/views.py:1181
+#: dcim/views.py:1867 netbox/navigation/menu.py:85
+#: templates/dcim/device/base.html:25 templates/dcim/device_list.html:15
+#: templates/dcim/devicetype/base.html:22 templates/dcim/module.html:22
+#: templates/dcim/moduletype/base.html:22
+msgid "Console Ports"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:116 dcim/views.py:957 dcim/views.py:1196
+#: dcim/views.py:1882 netbox/navigation/menu.py:86
+#: templates/dcim/device/base.html:28 templates/dcim/device_list.html:22
+#: templates/dcim/devicetype/base.html:25 templates/dcim/module.html:25
+#: templates/dcim/moduletype/base.html:25
+msgid "Console Server Ports"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:119 dcim/views.py:972 dcim/views.py:1211
+#: dcim/views.py:1897 netbox/navigation/menu.py:87
+#: templates/dcim/device/base.html:31 templates/dcim/device_list.html:29
+#: templates/dcim/devicetype/base.html:28 templates/dcim/module.html:28
+#: templates/dcim/moduletype/base.html:28
+msgid "Power Ports"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:122 dcim/views.py:987 dcim/views.py:1226
+#: dcim/views.py:1912 netbox/navigation/menu.py:88
+#: templates/dcim/device/base.html:34 templates/dcim/device_list.html:36
+#: templates/dcim/devicetype/base.html:31 templates/dcim/module.html:31
+#: templates/dcim/moduletype/base.html:31
+msgid "Power Outlets"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:128 dcim/views.py:1017 dcim/views.py:1256
+#: dcim/views.py:1948 netbox/navigation/menu.py:83
+#: templates/dcim/device/base.html:40 templates/dcim/devicetype/base.html:37
+#: templates/dcim/module.html:37 templates/dcim/moduletype/base.html:37
+msgid "Front Ports"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:131 dcim/views.py:1032 dcim/views.py:1271
+#: dcim/views.py:1963 netbox/navigation/menu.py:84
+#: templates/dcim/device/base.html:43 templates/dcim/device_list.html:50
+#: templates/dcim/devicetype/base.html:40 templates/dcim/module.html:40
+#: templates/dcim/moduletype/base.html:40
+msgid "Rear Ports"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:134 dcim/views.py:1062 dcim/views.py:2001
+#: netbox/navigation/menu.py:90 templates/dcim/device/base.html:49
+#: templates/dcim/device_list.html:57 templates/dcim/devicetype/base.html:46
+msgid "Device Bays"
+msgstr ""
+
+#: dcim/tables/devicetypes.py:137 dcim/views.py:1047 dcim/views.py:1982
+#: netbox/navigation/menu.py:89 templates/dcim/device/base.html:46
+#: templates/dcim/device_list.html:64 templates/dcim/devicetype/base.html:43
+msgid "Module Bays"
+msgstr ""
+
+#: dcim/tables/power.py:36 netbox/navigation/menu.py:263
+#: templates/dcim/powerpanel.html:53 templates/extras/configrevision.html:59
+msgid "Power Feeds"
+msgstr ""
+
+#: dcim/tables/power.py:80 templates/dcim/powerfeed.html:106
+msgid "Max Utilization"
+msgstr ""
+
+#: dcim/tables/power.py:84
+msgid "Available Power (VA)"
+msgstr ""
+
+#: dcim/tables/racks.py:29 dcim/tables/sites.py:138
+#: netbox/navigation/menu.py:25 netbox/navigation/menu.py:27
+msgid "Racks"
+msgstr ""
+
+#: dcim/tables/racks.py:73 templates/dcim/device.html:340
+#: templates/dcim/rack.html:102
+msgid "Height"
+msgstr ""
+
+#: dcim/tables/racks.py:85
+msgid "Space"
+msgstr ""
+
+#: dcim/tables/racks.py:96 templates/dcim/rack.html:112
+msgid "Outer Width"
+msgstr ""
+
+#: dcim/tables/racks.py:100 templates/dcim/rack.html:122
+msgid "Outer Depth"
+msgstr ""
+
+#: dcim/tables/racks.py:108
+msgid "Max Weight"
+msgstr ""
+
+#: dcim/tables/sites.py:30 dcim/tables/sites.py:57
+#: extras/forms/filtersets.py:334 extras/forms/model_forms.py:291
+#: ipam/forms/bulk_edit.py:130 ipam/forms/model_forms.py:154
+#: ipam/tables/asn.py:65 netbox/navigation/menu.py:16
+#: netbox/navigation/menu.py:18
+msgid "Sites"
+msgstr ""
+
+#: dcim/views.py:131
+#, python-brace-format
+msgid "Disconnected {count} {type}"
+msgstr ""
+
+#: dcim/views.py:692 netbox/navigation/menu.py:29
+msgid "Reservations"
+msgstr ""
+
+#: dcim/views.py:711
+msgid "Non-Racked Devices"
+msgstr ""
+
+#: dcim/views.py:2033 extras/forms/model_forms.py:351
+#: templates/extras/configcontext.html:10
+#: virtualization/forms/model_forms.py:226 virtualization/views.py:386
+msgid "Config Context"
+msgstr ""
+
+#: dcim/views.py:2043 virtualization/views.py:396
+msgid "Render Config"
+msgstr ""
+
+#: extras/choices.py:27 extras/forms/misc.py:14
+msgid "Text"
+msgstr ""
+
+#: extras/choices.py:28
+msgid "Text (long)"
+msgstr ""
+
+#: extras/choices.py:29
+msgid "Integer"
+msgstr ""
+
+#: extras/choices.py:30
+msgid "Decimal"
+msgstr ""
+
+#: extras/choices.py:31
+msgid "Boolean (true/false)"
+msgstr ""
+
+#: extras/choices.py:32
+msgid "Date"
+msgstr ""
+
+#: extras/choices.py:33
+msgid "Date & time"
+msgstr ""
+
+#: extras/choices.py:35
+msgid "JSON"
+msgstr ""
+
+#: extras/choices.py:36
+msgid "Selection"
+msgstr ""
+
+#: extras/choices.py:37
+msgid "Multiple selection"
+msgstr ""
+
+#: extras/choices.py:39
+msgid "Multiple objects"
+msgstr ""
+
+#: extras/choices.py:50 templates/extras/customfield.html:69
+#: wireless/choices.py:27
+msgid "Disabled"
+msgstr ""
+
+#: extras/choices.py:51
+msgid "Loose"
+msgstr ""
+
+#: extras/choices.py:52
+msgid "Exact"
+msgstr ""
+
+#: extras/choices.py:64
+msgid "Read/write"
+msgstr ""
+
+#: extras/choices.py:65
+msgid "Read-only"
+msgstr ""
+
+#: extras/choices.py:66
+msgid "Hidden"
+msgstr ""
+
+#: extras/choices.py:67
+msgid "Hidden (if unset)"
+msgstr ""
+
+#: extras/choices.py:94 templates/tenancy/contact.html:58
+#: tenancy/forms/bulk_edit.py:117 wireless/forms/model_forms.py:159
+msgid "Link"
+msgstr ""
+
+#: extras/choices.py:108
+msgid "Newest"
+msgstr ""
+
+#: extras/choices.py:109
+msgid "Oldest"
+msgstr ""
+
+#: extras/choices.py:125 templates/generic/object.html:51
+msgid "Updated"
+msgstr ""
+
+#: extras/choices.py:126
+msgid "Deleted"
+msgstr ""
+
+#: extras/choices.py:143 extras/choices.py:165
+msgid "Info"
+msgstr ""
+
+#: extras/choices.py:144 extras/choices.py:164
+msgid "Success"
+msgstr ""
+
+#: extras/choices.py:145 extras/choices.py:166
+msgid "Warning"
+msgstr ""
+
+#: extras/choices.py:146
+msgid "Danger"
+msgstr ""
+
+#: extras/choices.py:163 utilities/choices.py:190
+msgid "Default"
+msgstr ""
+
+#: extras/choices.py:167
+msgid "Failure"
+msgstr ""
+
+#: extras/choices.py:174
+msgid "Hourly"
+msgstr ""
+
+#: extras/choices.py:175
+msgid "12 hours"
+msgstr ""
+
+#: extras/choices.py:176
+msgid "Daily"
+msgstr ""
+
+#: extras/choices.py:177
+msgid "Weekly"
+msgstr ""
+
+#: extras/choices.py:178
+msgid "30 days"
+msgstr ""
+
+#: extras/choices.py:243 extras/tables/tables.py:283
+#: templates/dcim/virtualchassis_edit.html:108 templates/extras/webhook.html:33
+#: templates/generic/bulk_add_component.html:56
+#: templates/generic/object_edit.html:29 templates/generic/object_edit.html:70
+#: templates/ipam/inc/ipaddress_edit_header.html:10
+msgid "Create"
+msgstr ""
+
+#: extras/choices.py:244 extras/tables/tables.py:286
+#: templates/extras/webhook.html:37
+msgid "Update"
+msgstr ""
+
+#: extras/choices.py:245 extras/tables/tables.py:289
+#: templates/circuits/inc/circuit_termination.html:22
+#: templates/dcim/devicetype/component_templates.html:24
+#: templates/dcim/inc/panels/inventory_items.html:29
+#: templates/dcim/moduletype/component_templates.html:24
+#: templates/dcim/powerpanel.html:71 templates/extras/report_list.html:34
+#: templates/extras/script_list.html:33 templates/extras/webhook.html:41
+#: templates/generic/bulk_delete.html:18 templates/generic/bulk_delete.html:45
+#: templates/generic/object_delete.html:15 templates/htmx/delete_form.html:23
+#: templates/ipam/inc/panels/fhrp_groups.html:35
+#: templates/users/objectpermission.html:49
+#: utilities/templates/buttons/delete.html:9
+msgid "Delete"
+msgstr ""
+
+#: extras/choices.py:269 utilities/choices.py:143 utilities/choices.py:191
+msgid "Blue"
+msgstr ""
+
+#: extras/choices.py:270 utilities/choices.py:142 utilities/choices.py:192
+msgid "Indigo"
+msgstr ""
+
+#: extras/choices.py:271 utilities/choices.py:140 utilities/choices.py:193
+msgid "Purple"
+msgstr ""
+
+#: extras/choices.py:272 utilities/choices.py:137 utilities/choices.py:194
+msgid "Pink"
+msgstr ""
+
+#: extras/choices.py:273 utilities/choices.py:136 utilities/choices.py:195
+msgid "Red"
+msgstr ""
+
+#: extras/choices.py:274 utilities/choices.py:154 utilities/choices.py:196
+msgid "Orange"
+msgstr ""
+
+#: extras/choices.py:275 utilities/choices.py:152 utilities/choices.py:197
+msgid "Yellow"
+msgstr ""
+
+#: extras/choices.py:276 utilities/choices.py:149 utilities/choices.py:198
+msgid "Green"
+msgstr ""
+
+#: extras/choices.py:277 utilities/choices.py:146 utilities/choices.py:199
+msgid "Teal"
+msgstr ""
+
+#: extras/choices.py:278 utilities/choices.py:145 utilities/choices.py:200
+msgid "Cyan"
+msgstr ""
+
+#: extras/choices.py:279 utilities/choices.py:201
+msgid "Gray"
+msgstr ""
+
+#: extras/choices.py:280 utilities/choices.py:160 utilities/choices.py:202
+msgid "Black"
+msgstr ""
+
+#: extras/choices.py:281 utilities/choices.py:161 utilities/choices.py:203
+msgid "White"
+msgstr ""
+
+#: extras/dashboard/forms.py:38
+msgid "Widget type"
+msgstr ""
+
+#: extras/dashboard/widgets.py:146
+msgid "Note"
+msgstr ""
+
+#: extras/dashboard/widgets.py:147
+msgid "Display some arbitrary custom content. Markdown is supported."
+msgstr ""
+
+#: extras/dashboard/widgets.py:160
+msgid "Object Counts"
+msgstr ""
+
+#: extras/dashboard/widgets.py:161
+msgid ""
+"Display a set of NetBox models and the number of objects created for each "
+"type."
+msgstr ""
+
+#: extras/dashboard/widgets.py:171
+msgid "Filters to apply when counting the number of objects"
+msgstr ""
+
+#: extras/dashboard/widgets.py:207
+msgid "Object List"
+msgstr ""
+
+#: extras/dashboard/widgets.py:208
+msgid "Display an arbitrary list of objects."
+msgstr ""
+
+#: extras/dashboard/widgets.py:221
+msgid "The default number of objects to display"
+msgstr ""
+
+#: extras/dashboard/widgets.py:268
+msgid "RSS Feed"
+msgstr ""
+
+#: extras/dashboard/widgets.py:273
+msgid "Embed an RSS feed from an external website."
+msgstr ""
+
+#: extras/dashboard/widgets.py:280
+msgid "Feed URL"
+msgstr ""
+
+#: extras/dashboard/widgets.py:285
+msgid "The maximum number of objects to display"
+msgstr ""
+
+#: extras/dashboard/widgets.py:290
+msgid "How long to stored the cached content (in seconds)"
+msgstr ""
+
+#: extras/dashboard/widgets.py:342 templates/account/base.html:10
+#: templates/account/bookmarks.html:7 templates/inc/profile_button.html:29
+msgid "Bookmarks"
+msgstr ""
+
+#: extras/dashboard/widgets.py:346
+msgid "Show your personal bookmarks"
+msgstr ""
+
+#: extras/filtersets.py:176 extras/filtersets.py:511 extras/filtersets.py:539
+msgid "Data file (ID)"
+msgstr ""
+
+#: extras/filtersets.py:448 virtualization/forms/filtersets.py:111
+msgid "Cluster type"
+msgstr ""
+
+#: extras/filtersets.py:454 virtualization/filtersets.py:93
+#: virtualization/filtersets.py:143
+msgid "Cluster type (slug)"
+msgstr ""
+
+#: extras/filtersets.py:459 ipam/forms/bulk_edit.py:477
+#: ipam/forms/model_forms.py:587 virtualization/forms/filtersets.py:105
+msgid "Cluster group"
+msgstr ""
+
+#: extras/filtersets.py:465 virtualization/filtersets.py:132
+msgid "Cluster group (slug)"
+msgstr ""
+
+#: extras/filtersets.py:475 tenancy/forms/forms.py:16 tenancy/forms/forms.py:39
+msgid "Tenant group"
+msgstr ""
+
+#: extras/filtersets.py:481 tenancy/filtersets.py:151 tenancy/filtersets.py:171
+msgid "Tenant group (slug)"
+msgstr ""
+
+#: extras/filtersets.py:497 templates/extras/tag.html:12
+msgid "Tag"
+msgstr ""
+
+#: extras/filtersets.py:503
+msgid "Tag (slug)"
+msgstr ""
+
+#: extras/filtersets.py:563 extras/forms/filtersets.py:413
+msgid "Has local config context data"
+msgstr ""
+
+#: extras/filtersets.py:588
+msgid "User name"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:31 extras/forms/filtersets.py:58
+msgid "Group name"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:39 extras/forms/filtersets.py:66
+#: extras/tables/tables.py:72 templates/extras/customfield.html:39
+#: templates/generic/bulk_import.html:116
+msgid "Required"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:52 extras/forms/bulk_import.py:56
+#: extras/forms/filtersets.py:80 extras/models/customfields.py:187
+msgid "UI visibility"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:58 extras/forms/filtersets.py:83
+msgid "Is cloneable"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:97 extras/forms/filtersets.py:123
+msgid "New window"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:106
+msgid "Button class"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:123 extras/forms/filtersets.py:161
+#: extras/models/models.py:356
+msgid "MIME type"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:128 extras/forms/filtersets.py:164
+msgid "File extension"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:133 extras/forms/filtersets.py:168
+msgid "As attachment"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:161 extras/forms/filtersets.py:210
+#: extras/tables/tables.py:236 templates/extras/savedfilter.html:30
+msgid "Shared"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:182
+msgid "On create"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:187
+msgid "On update"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:192
+msgid "On delete"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:197
+msgid "On job start"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:202
+msgid "On job end"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:209 extras/forms/filtersets.py:239
+#: extras/models/models.py:100
+msgid "HTTP method"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:213 templates/extras/webhook.html:66
+msgid "Payload URL"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:218 extras/models/models.py:146
+msgid "SSL verification"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:221 templates/extras/webhook.html:74
+msgid "Secret"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:226
+msgid "CA file path"
+msgstr ""
+
+#: extras/forms/bulk_edit.py:261
+msgid "Is active"
+msgstr ""
+
+#: extras/forms/bulk_import.py:31 extras/forms/bulk_import.py:91
+#: extras/forms/bulk_import.py:107 extras/forms/bulk_import.py:131
+#: extras/forms/bulk_import.py:145 extras/forms/filtersets.py:111
+#: extras/forms/filtersets.py:157 extras/forms/filtersets.py:198
+#: extras/forms/model_forms.py:46 extras/forms/model_forms.py:119
+#: extras/forms/model_forms.py:147 extras/forms/model_forms.py:189
+#: extras/forms/model_forms.py:227
+msgid "Content types"
+msgstr ""
+
+#: extras/forms/bulk_import.py:34 extras/forms/bulk_import.py:94
+#: extras/forms/bulk_import.py:110 extras/forms/bulk_import.py:133
+#: extras/forms/bulk_import.py:148 tenancy/forms/bulk_import.py:96
+msgid "One or more assigned object types"
+msgstr ""
+
+#: extras/forms/bulk_import.py:39
+msgid "Field data type (e.g. text, integer, etc.)"
+msgstr ""
+
+#: extras/forms/bulk_import.py:42 extras/forms/filtersets.py:50
+#: extras/forms/filtersets.py:234 extras/forms/model_forms.py:51
+#: extras/forms/model_forms.py:215 tenancy/forms/filtersets.py:93
+msgid "Object type"
+msgstr ""
+
+#: extras/forms/bulk_import.py:46
+msgid "Object type (for object or multi-object fields)"
+msgstr ""
+
+#: extras/forms/bulk_import.py:49 extras/forms/filtersets.py:75
+msgid "Choice set"
+msgstr ""
+
+#: extras/forms/bulk_import.py:53
+msgid "Choice set (for selection fields)"
+msgstr ""
+
+#: extras/forms/bulk_import.py:58
+msgid "How the custom field is displayed in the user interface"
+msgstr ""
+
+#: extras/forms/bulk_import.py:74
+msgid "The base set of predefined choices to use (if any)"
+msgstr ""
+
+#: extras/forms/bulk_import.py:79
+msgid "Comma-separated list of field choices"
+msgstr ""
+
+#: extras/forms/bulk_import.py:174
+msgid "Assigned object type"
+msgstr ""
+
+#: extras/forms/bulk_import.py:179
+msgid "The classification of entry"
+msgstr ""
+
+#: extras/forms/filtersets.py:55
+msgid "Field type"
+msgstr ""
+
+#: extras/forms/filtersets.py:94 extras/tables/tables.py:87
+#: templates/generic/bulk_import.html:148
+msgid "Choices"
+msgstr ""
+
+#: extras/forms/filtersets.py:138 extras/forms/filtersets.py:302
+#: extras/forms/filtersets.py:392 extras/forms/model_forms.py:346
+#: templates/core/job.html:80 templates/extras/configcontext.html:86
+msgid "Data"
+msgstr ""
+
+#: extras/forms/filtersets.py:149 extras/forms/filtersets.py:316
+#: extras/forms/filtersets.py:402 utilities/choices.py:219
+#: utilities/forms/bulk_import.py:27
+msgid "Data file"
+msgstr ""
+
+#: extras/forms/filtersets.py:182
+msgid "Content type"
+msgstr ""
+
+#: extras/forms/filtersets.py:229 extras/forms/model_forms.py:234
+#: templates/extras/webhook.html:28
+msgid "Events"
+msgstr ""
+
+#: extras/forms/filtersets.py:253
+msgid "Object creations"
+msgstr ""
+
+#: extras/forms/filtersets.py:260
+msgid "Object updates"
+msgstr ""
+
+#: extras/forms/filtersets.py:267
+msgid "Object deletions"
+msgstr ""
+
+#: extras/forms/filtersets.py:274
+msgid "Job starts"
+msgstr ""
+
+#: extras/forms/filtersets.py:281 extras/forms/model_forms.py:250
+msgid "Job terminations"
+msgstr ""
+
+#: extras/forms/filtersets.py:290
+msgid "Tagged object type"
+msgstr ""
+
+#: extras/forms/filtersets.py:295
+msgid "Allowed object type"
+msgstr ""
+
+#: extras/forms/filtersets.py:324 extras/forms/model_forms.py:281
+#: netbox/navigation/menu.py:19
+msgid "Regions"
+msgstr ""
+
+#: extras/forms/filtersets.py:329 extras/forms/model_forms.py:286
+msgid "Site groups"
+msgstr ""
+
+#: extras/forms/filtersets.py:339 extras/forms/model_forms.py:296
+#: netbox/navigation/menu.py:21
+msgid "Locations"
+msgstr ""
+
+#: extras/forms/filtersets.py:344 extras/forms/model_forms.py:301
+msgid "Device types"
+msgstr ""
+
+#: extras/forms/filtersets.py:349 extras/forms/model_forms.py:306
+msgid "Roles"
+msgstr ""
+
+#: extras/forms/filtersets.py:359 extras/forms/model_forms.py:316
+msgid "Cluster types"
+msgstr ""
+
+#: extras/forms/filtersets.py:365 extras/forms/model_forms.py:321
+msgid "Cluster groups"
+msgstr ""
+
+#: extras/forms/filtersets.py:370 extras/forms/model_forms.py:326
+#: netbox/navigation/menu.py:224 netbox/navigation/menu.py:226
+#: templates/virtualization/clustertype.html:33
+#: virtualization/tables/clusters.py:23 virtualization/tables/clusters.py:45
+msgid "Clusters"
+msgstr ""
+
+#: extras/forms/filtersets.py:375 extras/forms/model_forms.py:331
+msgid "Tenant groups"
+msgstr ""
+
+#: extras/forms/filtersets.py:429 extras/forms/filtersets.py:470
+msgid "After"
+msgstr ""
+
+#: extras/forms/filtersets.py:434 extras/forms/filtersets.py:475
+msgid "Before"
+msgstr ""
+
+#: extras/forms/filtersets.py:465 extras/tables/tables.py:426
+#: templates/extras/htmx/report_result.html:43
+#: templates/extras/objectchange.html:34
+msgid "Time"
+msgstr ""
+
+#: extras/forms/filtersets.py:479 extras/tables/tables.py:440
+#: templates/extras/objectchange.html:50
+msgid "Action"
+msgstr ""
+
+#: extras/forms/mixins.py:71 extras/forms/model_forms.py:195
+#: templates/extras/savedfilter.html:10
+msgid "Saved Filter"
+msgstr ""
+
+#: extras/forms/model_forms.py:56
+msgid "Type of the related object (for object/multi-object fields only)"
+msgstr ""
+
+#: extras/forms/model_forms.py:64 templates/extras/customfield.html:11
+msgid "Custom Field"
+msgstr ""
+
+#: extras/forms/model_forms.py:67 templates/extras/customfield.html:60
+msgid "Behavior"
+msgstr ""
+
+#: extras/forms/model_forms.py:68
+msgid "Values"
+msgstr ""
+
+#: extras/forms/model_forms.py:69 extras/forms/model_forms.py:494
+#: templates/extras/configrevision.html:147
+msgid "Validation"
+msgstr ""
+
+#: extras/forms/model_forms.py:77
+msgid ""
+"The type of data stored in this field. For object/multi-object fields, "
+"select the related object type below."
+msgstr ""
+
+#: extras/forms/model_forms.py:80
+msgid ""
+"This will be displayed as help text for the form field. Markdown is "
+"supported."
+msgstr ""
+
+#: extras/forms/model_forms.py:97
+msgid ""
+"Enter one choice per line. An optional label may be specified for each "
+"choice by appending it with a comma. Example:"
+msgstr ""
+
+#: extras/forms/model_forms.py:125 templates/extras/customlink.html:10
+msgid "Custom Link"
+msgstr ""
+
+#: extras/forms/model_forms.py:126
+msgid "Templates"
+msgstr ""
+
+#: extras/forms/model_forms.py:138
+msgid ""
+"Jinja2 template code for the link text. Reference the object as "
+"{{ object }}
. Links which render as empty text will not be "
+"displayed."
+msgstr ""
+
+#: extras/forms/model_forms.py:141
+msgid ""
+"Jinja2 template code for the link URL. Reference the object as "
+"{{ object }}
."
+msgstr ""
+
+#: extras/forms/model_forms.py:152 extras/forms/model_forms.py:397
+msgid "Template code"
+msgstr ""
+
+#: extras/forms/model_forms.py:158 templates/extras/exporttemplate.html:17
+msgid "Export Template"
+msgstr ""
+
+#: extras/forms/model_forms.py:160
+msgid "Rendering"
+msgstr ""
+
+#: extras/forms/model_forms.py:174 extras/forms/model_forms.py:422
+msgid "Template content is populated from the remote source selected below."
+msgstr ""
+
+#: extras/forms/model_forms.py:181 extras/forms/model_forms.py:429
+msgid "Must specify either local content or a data file"
+msgstr ""
+
+#: extras/forms/model_forms.py:233 templates/extras/webhook.html:11
+msgid "Webhook"
+msgstr ""
+
+#: extras/forms/model_forms.py:235 templates/extras/webhook.html:57
+msgid "HTTP Request"
+msgstr ""
+
+#: extras/forms/model_forms.py:238 templates/extras/webhook.html:116
+msgid "Conditions"
+msgstr ""
+
+#: extras/forms/model_forms.py:239 templates/extras/webhook.html:82
+msgid "SSL"
+msgstr ""
+
+#: extras/forms/model_forms.py:246
+msgid "Creations"
+msgstr ""
+
+#: extras/forms/model_forms.py:247
+msgid "Updates"
+msgstr ""
+
+#: extras/forms/model_forms.py:248
+msgid "Deletions"
+msgstr ""
+
+#: extras/forms/model_forms.py:249
+msgid "Job executions"
+msgstr ""
+
+#: extras/forms/model_forms.py:262 users/forms/model_forms.py:285
+msgid "Object types"
+msgstr ""
+
+#: extras/forms/model_forms.py:336 netbox/navigation/menu.py:40
+#: tenancy/tables/tenants.py:22
+msgid "Tenants"
+msgstr ""
+
+#: extras/forms/model_forms.py:353 ipam/forms/filtersets.py:145
+#: templates/extras/configcontext.html:62 templates/ipam/ipaddress.html:62
+#: templates/ipam/vlan_edit.html:30 tenancy/forms/filtersets.py:87
+#: users/forms/model_forms.py:323
+msgid "Assignment"
+msgstr ""
+
+#: extras/forms/model_forms.py:379
+msgid "Data is populated from the remote source selected below."
+msgstr ""
+
+#: extras/forms/model_forms.py:385
+msgid "Must specify either local data or a data file"
+msgstr ""
+
+#: extras/forms/model_forms.py:404 templates/core/datafile.html:65
+msgid "Content"
+msgstr ""
+
+#: extras/forms/model_forms.py:488 templates/dcim/rack_elevation_list.html:6
+#: templates/extras/configrevision.html:43
+msgid "Rack Elevations"
+msgstr ""
+
+#: extras/forms/model_forms.py:490 netbox/navigation/menu.py:142
+#: templates/extras/configrevision.html:79
+msgid "IPAM"
+msgstr ""
+
+#: extras/forms/model_forms.py:491 templates/extras/configrevision.html:95
+msgid "Security"
+msgstr ""
+
+#: extras/forms/model_forms.py:492 templates/extras/configrevision.html:107
+msgid "Banners"
+msgstr ""
+
+#: extras/forms/model_forms.py:493 templates/extras/configrevision.html:131
+msgid "Pagination"
+msgstr ""
+
+#: extras/forms/model_forms.py:495 templates/account/preferences.html:6
+#: templates/extras/configrevision.html:159
+msgid "User Preferences"
+msgstr ""
+
+#: extras/forms/model_forms.py:499
+msgid "Config Revision"
+msgstr ""
+
+#: extras/forms/model_forms.py:537
+msgid "This parameter has been defined statically and cannot be modified."
+msgstr ""
+
+#: extras/forms/model_forms.py:545
+#, python-brace-format
+msgid "Current value: {value}"
+msgstr ""
+
+#: extras/forms/model_forms.py:547
+msgid " (default)"
+msgstr ""
+
+#: extras/forms/reports.py:18 extras/forms/scripts.py:24
+msgid "Schedule at"
+msgstr ""
+
+#: extras/forms/reports.py:19
+msgid "Schedule execution of report to a set time"
+msgstr ""
+
+#: extras/forms/reports.py:24 extras/forms/scripts.py:30
+msgid "Recurs every"
+msgstr ""
+
+#: extras/forms/reports.py:28
+msgid "Interval at which this report is re-run (in minutes)"
+msgstr ""
+
+#: extras/forms/reports.py:36 extras/forms/scripts.py:42
+#, python-brace-format
+msgid " (current time: {now})"
+msgstr ""
+
+#: extras/forms/reports.py:46 extras/forms/scripts.py:52
+msgid "Scheduled time must be in the future."
+msgstr ""
+
+#: extras/forms/scripts.py:18
+msgid "Commit changes"
+msgstr ""
+
+#: extras/forms/scripts.py:19
+msgid "Commit changes to the database (uncheck for a dry-run)"
+msgstr ""
+
+#: extras/forms/scripts.py:25
+msgid "Schedule execution of script to a set time"
+msgstr ""
+
+#: extras/forms/scripts.py:34
+msgid "Interval at which this script is re-run (in minutes)"
+msgstr ""
+
+#: extras/models/change_logging.py:23
+msgid "time"
+msgstr ""
+
+#: extras/models/change_logging.py:36
+msgid "user name"
+msgstr ""
+
+#: extras/models/change_logging.py:41
+msgid "request ID"
+msgstr ""
+
+#: extras/models/change_logging.py:46 extras/models/staging.py:69
+msgid "action"
+msgstr ""
+
+#: extras/models/change_logging.py:80
+msgid "pre-change data"
+msgstr ""
+
+#: extras/models/change_logging.py:86
+msgid "post-change data"
+msgstr ""
+
+#: extras/models/change_logging.py:96
+msgid "object change"
+msgstr ""
+
+#: extras/models/change_logging.py:97
+msgid "object changes"
+msgstr ""
+
+#: extras/models/configs.py:130
+msgid "config context"
+msgstr ""
+
+#: extras/models/configs.py:131
+msgid "config contexts"
+msgstr ""
+
+#: extras/models/configs.py:149 extras/models/configs.py:205
+msgid "JSON data must be in object form. Example:"
+msgstr ""
+
+#: extras/models/configs.py:169
+msgid ""
+"Local config context data takes precedence over source contexts in the final "
+"rendered config context"
+msgstr ""
+
+#: extras/models/configs.py:224
+msgid "template code"
+msgstr ""
+
+#: extras/models/configs.py:225
+msgid "Jinja2 template code."
+msgstr ""
+
+#: extras/models/configs.py:228
+msgid "environment parameters"
+msgstr ""
+
+#: extras/models/configs.py:233
+msgid ""
+"Any additional parameters to pass when constructing the Jinja2 "
+"environment."
+msgstr ""
+
+#: extras/models/configs.py:240
+msgid "config template"
+msgstr ""
+
+#: extras/models/configs.py:241
+msgid "config templates"
+msgstr ""
+
+#: extras/models/customfields.py:66
+msgid "The object(s) to which this field applies."
+msgstr ""
+
+#: extras/models/customfields.py:73
+msgid "The type of data this custom field holds"
+msgstr ""
+
+#: extras/models/customfields.py:80
+msgid "The type of NetBox object this field maps to (for object fields)"
+msgstr ""
+
+#: extras/models/customfields.py:86
+msgid "Internal field name"
+msgstr ""
+
+#: extras/models/customfields.py:90
+msgid "Only alphanumeric characters and underscores are allowed."
+msgstr ""
+
+#: extras/models/customfields.py:95
+msgid "Double underscores are not permitted in custom field names."
+msgstr ""
+
+#: extras/models/customfields.py:106
+msgid ""
+"Name of the field as displayed to users (if not provided, 'the field's name "
+"will be used)"
+msgstr ""
+
+#: extras/models/customfields.py:110 extras/models/models.py:264
+msgid "group name"
+msgstr ""
+
+#: extras/models/customfields.py:113
+msgid "Custom fields within the same group will be displayed together"
+msgstr ""
+
+#: extras/models/customfields.py:121
+msgid "required"
+msgstr ""
+
+#: extras/models/customfields.py:123
+msgid ""
+"If true, this field is required when creating new objects or editing an "
+"existing object."
+msgstr ""
+
+#: extras/models/customfields.py:126
+msgid "search weight"
+msgstr ""
+
+#: extras/models/customfields.py:129
+msgid ""
+"Weighting for search. Lower values are considered more important. Fields "
+"with a search weight of zero will be ignored."
+msgstr ""
+
+#: extras/models/customfields.py:134
+msgid "filter logic"
+msgstr ""
+
+#: extras/models/customfields.py:138
+msgid ""
+"Loose matches any instance of a given string; exact matches the entire field."
+msgstr ""
+
+#: extras/models/customfields.py:141
+msgid "default"
+msgstr ""
+
+#: extras/models/customfields.py:145
+msgid ""
+"Default value for the field (must be a JSON value). Encapsulate strings with "
+"double quotes (e.g. \"Foo\")."
+msgstr ""
+
+#: extras/models/customfields.py:150
+msgid "display weight"
+msgstr ""
+
+#: extras/models/customfields.py:151
+msgid "Fields with higher weights appear lower in a form."
+msgstr ""
+
+#: extras/models/customfields.py:156
+msgid "minimum value"
+msgstr ""
+
+#: extras/models/customfields.py:157
+msgid "Minimum allowed value (for numeric fields)"
+msgstr ""
+
+#: extras/models/customfields.py:162
+msgid "maximum value"
+msgstr ""
+
+#: extras/models/customfields.py:163
+msgid "Maximum allowed value (for numeric fields)"
+msgstr ""
+
+#: extras/models/customfields.py:169
+msgid "validation regex"
+msgstr ""
+
+#: extras/models/customfields.py:171
+#, python-brace-format
+msgid ""
+"Regular expression to enforce on text field values. Use ^ and $ to force "
+"matching of entire string. For example, ^[A-Z]{3}$
will limit "
+"values to exactly three uppercase letters."
+msgstr ""
+
+#: extras/models/customfields.py:179
+msgid "choice set"
+msgstr ""
+
+#: extras/models/customfields.py:188
+msgid "Specifies the visibility of custom field in the UI"
+msgstr ""
+
+#: extras/models/customfields.py:192
+msgid "is cloneable"
+msgstr ""
+
+#: extras/models/customfields.py:193
+msgid "Replicate this value when cloning objects"
+msgstr ""
+
+#: extras/models/customfields.py:206
+msgid "custom field"
+msgstr ""
+
+#: extras/models/customfields.py:207
+msgid "custom fields"
+msgstr ""
+
+#: extras/models/customfields.py:290
+#, python-brace-format
+msgid "Invalid default value \"{value}\": {error}"
+msgstr ""
+
+#: extras/models/customfields.py:297
+msgid "A minimum value may be set only for numeric fields"
+msgstr ""
+
+#: extras/models/customfields.py:299
+msgid "A maximum value may be set only for numeric fields"
+msgstr ""
+
+#: extras/models/customfields.py:309
+msgid "Regular expression validation is supported only for text and URL fields"
+msgstr ""
+
+#: extras/models/customfields.py:319
+msgid "Selection fields must specify a set of choices."
+msgstr ""
+
+#: extras/models/customfields.py:323
+msgid "Choices may be set only on selection fields."
+msgstr ""
+
+#: extras/models/customfields.py:330
+msgid "Object fields must define an object type."
+msgstr ""
+
+#: extras/models/customfields.py:335
+#, python-brace-format
+msgid "{type} fields may not define an object type."
+msgstr ""
+
+#: extras/models/customfields.py:415
+msgid "True"
+msgstr ""
+
+#: extras/models/customfields.py:416
+msgid "False"
+msgstr ""
+
+#: extras/models/customfields.py:498
+#, python-brace-format
+msgid "Values must match this regex: {regex}
"
+msgstr ""
+
+#: extras/models/customfields.py:513
+msgid "Field is set to read-only."
+msgstr ""
+
+#: extras/models/customfields.py:595
+msgid "Value must be a string."
+msgstr ""
+
+#: extras/models/customfields.py:597
+#, python-brace-format
+msgid "Value must match regex '{regex}'"
+msgstr ""
+
+#: extras/models/customfields.py:602
+msgid "Value must be an integer."
+msgstr ""
+
+#: extras/models/customfields.py:605 extras/models/customfields.py:620
+#, python-brace-format
+msgid "Value must be at least {minimum}"
+msgstr ""
+
+#: extras/models/customfields.py:609 extras/models/customfields.py:624
+#, python-brace-format
+msgid "Value must not exceed {maximum}"
+msgstr ""
+
+#: extras/models/customfields.py:617
+msgid "Value must be a decimal."
+msgstr ""
+
+#: extras/models/customfields.py:629
+msgid "Value must be true or false."
+msgstr ""
+
+#: extras/models/customfields.py:637
+msgid "Date values must be in ISO 8601 format (YYYY-MM-DD)."
+msgstr ""
+
+#: extras/models/customfields.py:646
+msgid "Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS)."
+msgstr ""
+
+#: extras/models/customfields.py:653
+#, python-brace-format
+msgid "Invalid choice ({value}) for choice set {choiceset}."
+msgstr ""
+
+#: extras/models/customfields.py:663
+#, python-brace-format
+msgid "Invalid choice(s) ({value}) for choice set {choiceset}."
+msgstr ""
+
+#: extras/models/customfields.py:672
+#, python-brace-format
+msgid "Value must be an object ID, not {type}"
+msgstr ""
+
+#: extras/models/customfields.py:678
+#, python-brace-format
+msgid "Value must be a list of object IDs, not {type}"
+msgstr ""
+
+#: extras/models/customfields.py:682
+#, python-brace-format
+msgid "Found invalid object ID: {id}"
+msgstr ""
+
+#: extras/models/customfields.py:685
+msgid "Required field cannot be empty."
+msgstr ""
+
+#: extras/models/customfields.py:704
+msgid "Base set of predefined choices (optional)"
+msgstr ""
+
+#: extras/models/customfields.py:716
+msgid "Choices are automatically ordered alphabetically"
+msgstr ""
+
+#: extras/models/customfields.py:723
+msgid "custom field choice set"
+msgstr ""
+
+#: extras/models/customfields.py:724
+msgid "custom field choice sets"
+msgstr ""
+
+#: extras/models/customfields.py:760
+msgid "Must define base or extra choices."
+msgstr ""
+
+#: extras/models/dashboard.py:19
+msgid "layout"
+msgstr ""
+
+#: extras/models/dashboard.py:23
+msgid "config"
+msgstr ""
+
+#: extras/models/dashboard.py:28
+msgid "dashboard"
+msgstr ""
+
+#: extras/models/dashboard.py:29
+msgid "dashboards"
+msgstr ""
+
+#: extras/models/models.py:50
+msgid "object types"
+msgstr ""
+
+#: extras/models/models.py:52
+msgid "The object(s) to which this Webhook applies."
+msgstr ""
+
+#: extras/models/models.py:60
+msgid "on create"
+msgstr ""
+
+#: extras/models/models.py:62
+msgid "Triggers when a matching object is created."
+msgstr ""
+
+#: extras/models/models.py:65
+msgid "on update"
+msgstr ""
+
+#: extras/models/models.py:67
+msgid "Triggers when a matching object is updated."
+msgstr ""
+
+#: extras/models/models.py:70
+msgid "on delete"
+msgstr ""
+
+#: extras/models/models.py:72
+msgid "Triggers when a matching object is deleted."
+msgstr ""
+
+#: extras/models/models.py:75
+msgid "on job start"
+msgstr ""
+
+#: extras/models/models.py:77
+msgid "Triggers when a job for a matching object is started."
+msgstr ""
+
+#: extras/models/models.py:80
+msgid "on job end"
+msgstr ""
+
+#: extras/models/models.py:82
+msgid "Triggers when a job for a matching object terminates."
+msgstr ""
+
+#: extras/models/models.py:88
+msgid ""
+"This URL will be called using the HTTP method defined when the webhook is "
+"called. Jinja2 template processing is supported with the same context as the "
+"request body."
+msgstr ""
+
+#: extras/models/models.py:105
+msgid "HTTP content type"
+msgstr ""
+
+#: extras/models/models.py:107
+msgid ""
+"The complete list of official content types is available here."
+msgstr ""
+
+#: extras/models/models.py:112
+msgid "additional headers"
+msgstr ""
+
+#: extras/models/models.py:115
+msgid ""
+"User-supplied HTTP headers to be sent with the request in addition to the "
+"HTTP content type. Headers should be defined in the format Name: "
+"Value
. Jinja2 template processing is supported with the same context "
+"as the request body (below)."
+msgstr ""
+
+#: extras/models/models.py:121
+msgid "body template"
+msgstr ""
+
+#: extras/models/models.py:124
+msgid ""
+"Jinja2 template for a custom request body. If blank, a JSON object "
+"representing the change will be included. Available context data includes: "
+"event
, model
, timestamp
, "
+"username
, request_id
, and data
."
+msgstr ""
+
+#: extras/models/models.py:130
+msgid "secret"
+msgstr ""
+
+#: extras/models/models.py:134
+msgid ""
+"When provided, the request will include a X-Hook-Signature
"
+"header containing a HMAC hex digest of the payload body using the secret as "
+"the key. The secret is not transmitted in the request."
+msgstr ""
+
+#: extras/models/models.py:139
+msgid "conditions"
+msgstr ""
+
+#: extras/models/models.py:142
+msgid ""
+"A set of conditions which determine whether the webhook will be generated."
+msgstr ""
+
+#: extras/models/models.py:147
+msgid "Enable SSL certificate verification. Disable with caution!"
+msgstr ""
+
+#: extras/models/models.py:153 templates/extras/webhook.html:91
+msgid "CA File Path"
+msgstr ""
+
+#: extras/models/models.py:155
+msgid ""
+"The specific CA certificate file to use for SSL verification. Leave blank to "
+"use the system defaults."
+msgstr ""
+
+#: extras/models/models.py:167
+msgid "webhook"
+msgstr ""
+
+#: extras/models/models.py:168
+msgid "webhooks"
+msgstr ""
+
+#: extras/models/models.py:188
+msgid ""
+"At least one event type must be selected: create, update, delete, job_start, "
+"and/or job_end."
+msgstr ""
+
+#: extras/models/models.py:200
+msgid "Do not specify a CA certificate file if SSL verification is disabled."
+msgstr ""
+
+#: extras/models/models.py:240
+msgid "The object type(s) to which this link applies."
+msgstr ""
+
+#: extras/models/models.py:252
+msgid "link text"
+msgstr ""
+
+#: extras/models/models.py:253
+msgid "Jinja2 template code for link text"
+msgstr ""
+
+#: extras/models/models.py:256
+msgid "link URL"
+msgstr ""
+
+#: extras/models/models.py:257
+msgid "Jinja2 template code for link URL"
+msgstr ""
+
+#: extras/models/models.py:267
+msgid "Links with the same group will appear as a dropdown menu"
+msgstr ""
+
+#: extras/models/models.py:270
+msgid "button class"
+msgstr ""
+
+#: extras/models/models.py:274
+msgid ""
+"The class of the first link in a group will be used for the dropdown button"
+msgstr ""
+
+#: extras/models/models.py:277
+msgid "new window"
+msgstr ""
+
+#: extras/models/models.py:279
+msgid "Force link to open in a new window"
+msgstr ""
+
+#: extras/models/models.py:288
+msgid "custom link"
+msgstr ""
+
+#: extras/models/models.py:289
+msgid "custom links"
+msgstr ""
+
+#: extras/models/models.py:336
+msgid "The object type(s) to which this template applies."
+msgstr ""
+
+#: extras/models/models.py:349
+msgid ""
+"Jinja2 template code. The list of objects being exported is passed as a "
+"context variable named queryset
."
+msgstr ""
+
+#: extras/models/models.py:357
+msgid "Defaults to text/plain; charset=utf-8
"
+msgstr ""
+
+#: extras/models/models.py:360
+msgid "file extension"
+msgstr ""
+
+#: extras/models/models.py:363
+msgid "Extension to append to the rendered filename"
+msgstr ""
+
+#: extras/models/models.py:366
+msgid "as attachment"
+msgstr ""
+
+#: extras/models/models.py:368
+msgid "Download file as attachment"
+msgstr ""
+
+#: extras/models/models.py:377
+msgid "export template"
+msgstr ""
+
+#: extras/models/models.py:378
+msgid "export templates"
+msgstr ""
+
+#: extras/models/models.py:395
+#, python-brace-format
+msgid "\"{name}\" is a reserved name. Please choose a different name."
+msgstr ""
+
+#: extras/models/models.py:445
+msgid "The object type(s) to which this filter applies."
+msgstr ""
+
+#: extras/models/models.py:477
+msgid "shared"
+msgstr ""
+
+#: extras/models/models.py:490
+msgid "saved filter"
+msgstr ""
+
+#: extras/models/models.py:491
+msgid "saved filters"
+msgstr ""
+
+#: extras/models/models.py:509
+msgid "Filter parameters must be stored as a dictionary of keyword arguments."
+msgstr ""
+
+#: extras/models/models.py:537
+msgid "image height"
+msgstr ""
+
+#: extras/models/models.py:540
+msgid "image width"
+msgstr ""
+
+#: extras/models/models.py:554
+msgid "image attachment"
+msgstr ""
+
+#: extras/models/models.py:555
+msgid "image attachments"
+msgstr ""
+
+#: extras/models/models.py:623
+msgid "kind"
+msgstr ""
+
+#: extras/models/models.py:634
+msgid "journal entry"
+msgstr ""
+
+#: extras/models/models.py:635
+msgid "journal entries"
+msgstr ""
+
+#: extras/models/models.py:651
+#, python-brace-format
+msgid "Journaling is not supported for this object type ({type})."
+msgstr ""
+
+#: extras/models/models.py:690
+msgid "bookmark"
+msgstr ""
+
+#: extras/models/models.py:691
+msgid "bookmarks"
+msgstr ""
+
+#: extras/models/models.py:708
+msgid "comment"
+msgstr ""
+
+#: extras/models/models.py:715
+msgid "configuration data"
+msgstr ""
+
+#: extras/models/models.py:722
+msgid "config revision"
+msgstr ""
+
+#: extras/models/models.py:723
+msgid "config revisions"
+msgstr ""
+
+#: extras/models/models.py:727
+msgid "Default configuration"
+msgstr ""
+
+#: extras/models/models.py:729
+msgid "Current configuration"
+msgstr ""
+
+#: extras/models/models.py:730
+#, python-brace-format
+msgid "Config revision #{id}"
+msgstr ""
+
+#: extras/models/reports.py:46
+msgid "report module"
+msgstr ""
+
+#: extras/models/reports.py:47
+msgid "report modules"
+msgstr ""
+
+#: extras/models/scripts.py:46
+msgid "script module"
+msgstr ""
+
+#: extras/models/scripts.py:47
+msgid "script modules"
+msgstr ""
+
+#: extras/models/search.py:22
+msgid "timestamp"
+msgstr ""
+
+#: extras/models/search.py:37
+msgid "field"
+msgstr ""
+
+#: extras/models/search.py:45
+msgid "value"
+msgstr ""
+
+#: extras/models/search.py:54
+msgid "cached value"
+msgstr ""
+
+#: extras/models/search.py:55
+msgid "cached values"
+msgstr ""
+
+#: extras/models/staging.py:44
+msgid "branch"
+msgstr ""
+
+#: extras/models/staging.py:45
+msgid "branches"
+msgstr ""
+
+#: extras/models/staging.py:94
+msgid "staged change"
+msgstr ""
+
+#: extras/models/staging.py:95
+msgid "staged changes"
+msgstr ""
+
+#: extras/models/tags.py:44
+msgid "The object type(s) to which this this tag can be applied."
+msgstr ""
+
+#: extras/models/tags.py:53
+msgid "tag"
+msgstr ""
+
+#: extras/models/tags.py:54
+msgid "tags"
+msgstr ""
+
+#: extras/models/tags.py:80
+msgid "tagged item"
+msgstr ""
+
+#: extras/models/tags.py:81
+msgid "tagged items"
+msgstr ""
+
+#: extras/tables/tables.py:48 users/forms/filtersets.py:47 users/tables.py:39
+msgid "Is Active"
+msgstr ""
+
+#: extras/tables/tables.py:69 extras/tables/tables.py:141
+#: extras/tables/tables.py:165 extras/tables/tables.py:230
+#: extras/tables/tables.py:277
+msgid "Content Types"
+msgstr ""
+
+#: extras/tables/tables.py:75 templates/extras/customfield.html:82
+msgid "UI Visibility"
+msgstr ""
+
+#: extras/tables/tables.py:82 templates/extras/customfield.html:48
+msgid "Choice Set"
+msgstr ""
+
+#: extras/tables/tables.py:90
+msgid "Is Cloneable"
+msgstr ""
+
+#: extras/tables/tables.py:120
+msgid "Count"
+msgstr ""
+
+#: extras/tables/tables.py:123
+msgid "Order Alphabetically"
+msgstr ""
+
+#: extras/tables/tables.py:147 templates/extras/customlink.html:34
+msgid "New Window"
+msgstr ""
+
+#: extras/tables/tables.py:168
+msgid "As Attachment"
+msgstr ""
+
+#: extras/tables/tables.py:175 extras/tables/tables.py:367
+#: extras/tables/tables.py:402 templates/core/datafile.html:32
+#: templates/dcim/device/render_config.html:23
+#: templates/extras/configcontext.html:40
+#: templates/extras/configtemplate.html:32
+#: templates/extras/exporttemplate.html:51
+#: templates/generic/bulk_import.html:30
+#: templates/virtualization/virtualmachine/render_config.html:23
+msgid "Data File"
+msgstr ""
+
+#: extras/tables/tables.py:180 extras/tables/tables.py:379
+#: extras/tables/tables.py:407
+msgid "Synced"
+msgstr ""
+
+#: extras/tables/tables.py:200
+msgid "Content Type"
+msgstr ""
+
+#: extras/tables/tables.py:207
+msgid "Image"
+msgstr ""
+
+#: extras/tables/tables.py:212
+msgid "Size (Bytes)"
+msgstr ""
+
+#: extras/tables/tables.py:255 extras/tables/tables.py:326
+#: templates/extras/customfield.html:92
+#: templates/users/objectpermission.html:68 users/tables.py:83
+msgid "Object Types"
+msgstr ""
+
+#: extras/tables/tables.py:292
+msgid "Job Start"
+msgstr ""
+
+#: extras/tables/tables.py:295
+msgid "Job End"
+msgstr ""
+
+#: extras/tables/tables.py:298
+msgid "SSL Validation"
+msgstr ""
+
+#: extras/tables/tables.py:436 templates/account/profile.html:20
+#: templates/users/user.html:22
+msgid "Full Name"
+msgstr ""
+
+#: extras/tables/tables.py:453 templates/extras/objectchange.html:72
+msgid "Request ID"
+msgstr ""
+
+#: extras/tables/tables.py:490
+msgid "Comments (Short)"
+msgstr ""
+
+#: extras/views.py:836
+msgid "Your dashboard has been reset."
+msgstr ""
+
+#: ipam/api/field_serializers.py:17
+msgid "Enter a valid IPv4 or IPv6 address with optional mask."
+msgstr ""
+
+#: ipam/api/field_serializers.py:24
+#, python-brace-format
+msgid "Invalid IP address format: {data}"
+msgstr ""
+
+#: ipam/api/field_serializers.py:37
+msgid "Enter a valid IPv4 or IPv6 prefix and mask in CIDR notation."
+msgstr ""
+
+#: ipam/api/field_serializers.py:44
+#, python-brace-format
+msgid "Invalid IP prefix format: {data}"
+msgstr ""
+
+#: ipam/choices.py:30
+msgid "Container"
+msgstr ""
+
+#: ipam/choices.py:72
+msgid "DHCP"
+msgstr ""
+
+#: ipam/choices.py:73
+msgid "SLAAC"
+msgstr ""
+
+#: ipam/choices.py:89
+msgid "Loopback"
+msgstr ""
+
+#: ipam/choices.py:90 tenancy/choices.py:18
+msgid "Secondary"
+msgstr ""
+
+#: ipam/choices.py:91
+msgid "Anycast"
+msgstr ""
+
+#: ipam/choices.py:115
+msgid "Standard"
+msgstr ""
+
+#: ipam/choices.py:120
+msgid "CheckPoint"
+msgstr ""
+
+#: ipam/choices.py:123
+msgid "Cisco"
+msgstr ""
+
+#: ipam/choices.py:137
+msgid "Plaintext"
+msgstr ""
+
+#: ipam/filtersets.py:47 ipam/filtersets.py:1068
+msgid "Import target"
+msgstr ""
+
+#: ipam/filtersets.py:53 ipam/filtersets.py:1074
+msgid "Import target (name)"
+msgstr ""
+
+#: ipam/filtersets.py:58 ipam/filtersets.py:1079
+msgid "Export target"
+msgstr ""
+
+#: ipam/filtersets.py:64 ipam/filtersets.py:1085
+msgid "Export target (name)"
+msgstr ""
+
+#: ipam/filtersets.py:85
+msgid "Importing VRF"
+msgstr ""
+
+#: ipam/filtersets.py:91
+msgid "Import VRF (RD)"
+msgstr ""
+
+#: ipam/filtersets.py:96
+msgid "Exporting VRF"
+msgstr ""
+
+#: ipam/filtersets.py:102
+msgid "Export VRF (RD)"
+msgstr ""
+
+#: ipam/filtersets.py:132 ipam/filtersets.py:247 ipam/forms/model_forms.py:231
+#: ipam/tables/ip.py:211 templates/ipam/prefix.html:11
+msgid "Prefix"
+msgstr ""
+
+#: ipam/filtersets.py:136 ipam/filtersets.py:175 ipam/filtersets.py:198
+msgid "RIR (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:142 ipam/filtersets.py:181 ipam/filtersets.py:204
+msgid "RIR (slug)"
+msgstr ""
+
+#: ipam/filtersets.py:251
+msgid "Within prefix"
+msgstr ""
+
+#: ipam/filtersets.py:255
+msgid "Within and including prefix"
+msgstr ""
+
+#: ipam/filtersets.py:259
+msgid "Prefixes which contain this prefix or IP"
+msgstr ""
+
+#: ipam/filtersets.py:338 ipam/filtersets.py:1191
+msgid "VLAN (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:342 ipam/filtersets.py:1186
+msgid "VLAN number (1-4094)"
+msgstr ""
+
+#: ipam/filtersets.py:436 ipam/filtersets.py:440 ipam/filtersets.py:532
+#: ipam/forms/model_forms.py:446 templates/tenancy/contact.html:54
+#: tenancy/forms/bulk_edit.py:112
+msgid "Address"
+msgstr ""
+
+#: ipam/filtersets.py:444
+msgid "Ranges which contain this prefix or IP"
+msgstr ""
+
+#: ipam/filtersets.py:472 ipam/filtersets.py:528
+msgid "Parent prefix"
+msgstr ""
+
+#: ipam/filtersets.py:536 ipam/forms/bulk_edit.py:328
+#: ipam/forms/filtersets.py:195 ipam/forms/filtersets.py:320
+msgid "Mask length"
+msgstr ""
+
+#: ipam/filtersets.py:572 ipam/filtersets.py:807 ipam/filtersets.py:1026
+#: ipam/filtersets.py:1149
+msgid "Virtual machine (name)"
+msgstr ""
+
+#: ipam/filtersets.py:577 ipam/filtersets.py:812 ipam/filtersets.py:1020
+#: ipam/filtersets.py:1154 virtualization/filtersets.py:273
+msgid "Virtual machine (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:583 ipam/filtersets.py:1160
+msgid "Interface (name)"
+msgstr ""
+
+#: ipam/filtersets.py:588 ipam/filtersets.py:1165
+msgid "Interface (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:594 ipam/filtersets.py:1171
+msgid "VM interface (name)"
+msgstr ""
+
+#: ipam/filtersets.py:599
+msgid "VM interface (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:604
+msgid "FHRP group (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:608
+msgid "Is assigned to an interface"
+msgstr ""
+
+#: ipam/filtersets.py:612
+msgid "Is assigned"
+msgstr ""
+
+#: ipam/filtersets.py:1031
+msgid "IP address (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:1037 ipam/models/ip.py:786
+msgid "IP address"
+msgstr ""
+
+#: ipam/filtersets.py:1112
+msgid "L2VPN (slug)"
+msgstr ""
+
+#: ipam/filtersets.py:1176
+msgid "VM Interface (ID)"
+msgstr ""
+
+#: ipam/filtersets.py:1182
+msgid "VLAN (name)"
+msgstr ""
+
+#: ipam/forms/bulk_create.py:14
+msgid "Address pattern"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:87
+msgid "Is private"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:108 ipam/forms/bulk_edit.py:137
+#: ipam/forms/bulk_edit.py:162 ipam/forms/bulk_import.py:91
+#: ipam/forms/bulk_import.py:111 ipam/forms/bulk_import.py:131
+#: ipam/forms/filtersets.py:113 ipam/forms/filtersets.py:128
+#: ipam/forms/filtersets.py:151 ipam/forms/model_forms.py:95
+#: ipam/forms/model_forms.py:110 ipam/forms/model_forms.py:132
+#: ipam/forms/model_forms.py:150 ipam/models/asns.py:31 ipam/models/asns.py:103
+#: ipam/models/ip.py:70 ipam/models/ip.py:89 ipam/tables/asn.py:20
+#: ipam/tables/asn.py:45 templates/ipam/aggregate.html:19
+#: templates/ipam/asn.html:28 templates/ipam/asnrange.html:20
+#: templates/ipam/rir.html:20
+msgid "RIR"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:170
+msgid "Date added"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:231
+msgid "Prefix length"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:254 ipam/forms/filtersets.py:240
+#: templates/ipam/prefix.html:86
+msgid "Is a pool"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:259 ipam/forms/bulk_edit.py:303
+#: ipam/models/ip.py:271 ipam/models/ip.py:538
+#, python-format
+msgid "Treat as 100% utilized"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:351 ipam/models/ip.py:771
+msgid "DNS name"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:372 ipam/forms/bulk_edit.py:571
+#: ipam/forms/bulk_import.py:396 ipam/forms/bulk_import.py:480
+#: ipam/forms/bulk_import.py:506 ipam/forms/filtersets.py:379
+#: ipam/forms/filtersets.py:513 templates/ipam/fhrpgroup.html:23
+#: templates/ipam/inc/panels/fhrp_groups.html:11 templates/ipam/service.html:35
+#: templates/ipam/servicetemplate.html:20
+msgid "Protocol"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:379 ipam/forms/filtersets.py:386
+#: ipam/tables/fhrp.py:22 templates/ipam/fhrpgroup.html:27
+msgid "Group ID"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:384 ipam/forms/filtersets.py:391
+#: wireless/forms/bulk_edit.py:67 wireless/forms/bulk_edit.py:114
+#: wireless/forms/bulk_import.py:62 wireless/forms/bulk_import.py:65
+#: wireless/forms/bulk_import.py:104 wireless/forms/bulk_import.py:107
+#: wireless/forms/filtersets.py:53 wireless/forms/filtersets.py:87
+msgid "Authentication type"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:389 ipam/forms/filtersets.py:395
+msgid "Authentication key"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:406 ipam/forms/filtersets.py:372
+#: ipam/forms/model_forms.py:457 netbox/navigation/menu.py:356
+#: templates/ipam/fhrpgroup.html:51
+#: templates/wireless/inc/authentication_attrs.html:5
+#: wireless/forms/bulk_edit.py:90 wireless/forms/bulk_edit.py:137
+#: wireless/forms/filtersets.py:35 wireless/forms/filtersets.py:75
+#: wireless/forms/model_forms.py:56 wireless/forms/model_forms.py:161
+msgid "Authentication"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:416
+msgid "Minimum child VLAN VID"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:422
+msgid "Maximum child VLAN VID"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:430 ipam/forms/model_forms.py:529
+msgid "Scope type"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:491 ipam/forms/model_forms.py:602
+#: ipam/tables/vlans.py:71 templates/ipam/vlangroup.html:39
+msgid "Scope"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:562
+msgid "Site & Group"
+msgstr ""
+
+#: ipam/forms/bulk_edit.py:576 ipam/forms/model_forms.py:665
+#: ipam/forms/model_forms.py:699 ipam/tables/services.py:19
+#: ipam/tables/services.py:49 templates/ipam/service.html:39
+#: templates/ipam/servicetemplate.html:24
+msgid "Ports"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:50
+msgid "Import route targets"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:56
+msgid "Export route targets"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:94 ipam/forms/bulk_import.py:114
+#: ipam/forms/bulk_import.py:134
+msgid "Assigned RIR"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:184
+msgid "VLAN's group (if any)"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:187 ipam/forms/bulk_import.py:564
+#: ipam/forms/filtersets.py:603 ipam/forms/model_forms.py:221
+#: ipam/forms/model_forms.py:804 ipam/models/vlans.py:213 ipam/tables/ip.py:254
+#: templates/ipam/l2vpntermination_edit.html:17 templates/ipam/prefix.html:61
+#: templates/ipam/vlan.html:12 templates/ipam/vlan/base.html:6
+#: templates/ipam/vlan_edit.html:10 templates/wireless/wirelesslan.html:31
+#: wireless/forms/bulk_edit.py:54 wireless/forms/bulk_import.py:48
+#: wireless/forms/model_forms.py:49 wireless/models.py:101
+msgid "VLAN"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:310
+msgid "Parent device of assigned interface (if any)"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:313 ipam/forms/bulk_import.py:499
+#: ipam/forms/bulk_import.py:550 ipam/forms/model_forms.py:693
+#: virtualization/filtersets.py:279 virtualization/forms/bulk_edit.py:197
+#: virtualization/forms/bulk_import.py:145
+#: virtualization/forms/filtersets.py:200
+#: virtualization/forms/model_forms.py:280
+msgid "Virtual machine"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:317
+msgid "Parent VM of assigned interface (if any)"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:324
+msgid "Assigned interface"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:327
+msgid "Is primary"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:328
+msgid "Make this the primary IP for the assigned device"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:367
+msgid "No device or virtual machine specified; cannot set as primary IP"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:371
+msgid "No interface specified; cannot set as primary IP"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:400
+msgid "Auth type"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:415
+msgid "Scope type (app & model)"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:421
+#, python-brace-format
+msgid "Minimum child VLAN VID (default: {minimum})"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:427
+#, python-brace-format
+msgid "Maximum child VLAN VID (default: {maximum})"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:451
+msgid "Assigned VLAN group"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:482 ipam/forms/bulk_import.py:508
+msgid "IP protocol"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:496
+msgid "Required if not assigned to a VM"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:503
+msgid "Required if not assigned to a device"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:526
+msgid "L2VPN type"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:547
+msgid "Parent device (for interface)"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:554
+msgid "Parent virtual machine (for interface)"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:561
+msgid "Assigned interface (device or VM)"
+msgstr ""
+
+#: ipam/forms/bulk_import.py:594
+msgid "Cannot import device and VM interface terminations simultaneously."
+msgstr ""
+
+#: ipam/forms/bulk_import.py:596
+msgid "Each termination must specify either an interface or a VLAN."
+msgstr ""
+
+#: ipam/forms/bulk_import.py:598
+msgid "Cannot assign both an interface and a VLAN."
+msgstr ""
+
+#: ipam/forms/filtersets.py:50 ipam/forms/model_forms.py:62
+#: ipam/forms/model_forms.py:780 netbox/navigation/menu.py:177
+msgid "Route Targets"
+msgstr ""
+
+#: ipam/forms/filtersets.py:56 ipam/forms/filtersets.py:544
+#: ipam/forms/model_forms.py:49 ipam/forms/model_forms.py:767
+msgid "Import targets"
+msgstr ""
+
+#: ipam/forms/filtersets.py:61 ipam/forms/filtersets.py:549
+#: ipam/forms/model_forms.py:54 ipam/forms/model_forms.py:772
+msgid "Export targets"
+msgstr ""
+
+#: ipam/forms/filtersets.py:76
+msgid "Imported by VRF"
+msgstr ""
+
+#: ipam/forms/filtersets.py:81
+msgid "Exported by VRF"
+msgstr ""
+
+#: ipam/forms/filtersets.py:90 ipam/tables/ip.py:89 templates/ipam/rir.html:33
+msgid "Private"
+msgstr ""
+
+#: ipam/forms/filtersets.py:108 ipam/forms/filtersets.py:190
+#: ipam/forms/filtersets.py:265 ipam/forms/filtersets.py:315
+msgid "Address family"
+msgstr ""
+
+#: ipam/forms/filtersets.py:122 templates/ipam/asnrange.html:26
+msgid "Range"
+msgstr ""
+
+#: ipam/forms/filtersets.py:131
+msgid "Start"
+msgstr ""
+
+#: ipam/forms/filtersets.py:135
+msgid "End"
+msgstr ""
+
+#: ipam/forms/filtersets.py:185
+msgid "Search within"
+msgstr ""
+
+#: ipam/forms/filtersets.py:206 ipam/forms/filtersets.py:331
+msgid "Present in VRF"
+msgstr ""
+
+#: ipam/forms/filtersets.py:247 ipam/forms/filtersets.py:286
+#, python-format
+msgid "Marked as 100% utilized"
+msgstr ""
+
+#: ipam/forms/filtersets.py:301
+msgid "Device/VM"
+msgstr ""
+
+#: ipam/forms/filtersets.py:336
+msgid "Assigned Device"
+msgstr ""
+
+#: ipam/forms/filtersets.py:341
+msgid "Assigned VM"
+msgstr ""
+
+#: ipam/forms/filtersets.py:355
+msgid "Assigned to an interface"
+msgstr ""
+
+#: ipam/forms/filtersets.py:362 templates/ipam/ipaddress.html:54
+msgid "DNS Name"
+msgstr ""
+
+#: ipam/forms/filtersets.py:404 ipam/forms/filtersets.py:496
+#: ipam/models/vlans.py:154 templates/ipam/vlan.html:34
+msgid "VLAN ID"
+msgstr ""
+
+#: ipam/forms/filtersets.py:436
+msgid "Minimum VID"
+msgstr ""
+
+#: ipam/forms/filtersets.py:442
+msgid "Maximum VID"
+msgstr ""
+
+#: ipam/forms/filtersets.py:518
+msgid "Port"
+msgstr ""
+
+#: ipam/forms/filtersets.py:558 ipam/tables/ip.py:424
+#: templates/ipam/l2vpntermination.html:19
+msgid "Assigned Object"
+msgstr ""
+
+#: ipam/forms/filtersets.py:570
+msgid "Assigned Object Type"
+msgstr ""
+
+#: ipam/forms/filtersets.py:612 ipam/tables/vlans.py:191
+#: templates/ipam/ipaddress_edit.html:47
+#: templates/ipam/l2vpntermination_edit.html:27
+#: templates/ipam/service_create.html:22 templates/ipam/service_edit.html:21
+#: templates/virtualization/virtualmachine.html:13
+#: templates/virtualization/vminterface.html:24
+#: virtualization/forms/filtersets.py:186
+#: virtualization/forms/model_forms.py:221
+#: virtualization/tables/virtualmachines.py:110
+msgid "Virtual Machine"
+msgstr ""
+
+#: ipam/forms/model_forms.py:115 ipam/tables/ip.py:116
+#: templates/ipam/aggregate.html:11 templates/ipam/prefix.html:38
+msgid "Aggregate"
+msgstr ""
+
+#: ipam/forms/model_forms.py:136 templates/ipam/asnrange.html:12
+msgid "ASN Range"
+msgstr ""
+
+#: ipam/forms/model_forms.py:232
+msgid "Site/VLAN Assignment"
+msgstr ""
+
+#: ipam/forms/model_forms.py:258 templates/ipam/iprange.html:11
+msgid "IP Range"
+msgstr ""
+
+#: ipam/forms/model_forms.py:287 ipam/forms/model_forms.py:456
+#: templates/ipam/fhrpgroup.html:19 templates/ipam/ipaddress_edit.html:52
+msgid "FHRP Group"
+msgstr ""
+
+#: ipam/forms/model_forms.py:302
+msgid "Make this the primary IP for the device/VM"
+msgstr ""
+
+#: ipam/forms/model_forms.py:353
+msgid "An IP address can only be assigned to a single object."
+msgstr ""
+
+#: ipam/forms/model_forms.py:359 ipam/models/ip.py:877
+msgid ""
+"Cannot reassign IP address while it is designated as the primary IP for the "
+"parent object"
+msgstr ""
+
+#: ipam/forms/model_forms.py:369
+msgid ""
+"Only IP addresses assigned to an interface can be designated as primary IPs."
+msgstr ""
+
+#: ipam/forms/model_forms.py:375
+#, python-brace-format
+msgid "{ip} is a network ID, which may not be assigned to an interface."
+msgstr ""
+
+#: ipam/forms/model_forms.py:381
+#, python-brace-format
+msgid "{ip} is a broadcast address, which may not be assigned to an interface."
+msgstr ""
+
+#: ipam/forms/model_forms.py:458
+msgid "Virtual IP Address"
+msgstr ""
+
+#: ipam/forms/model_forms.py:600 ipam/forms/model_forms.py:639
+#: ipam/tables/ip.py:250 templates/ipam/vlan_edit.html:37
+#: templates/ipam/vlangroup.html:27
+msgid "VLAN Group"
+msgstr ""
+
+#: ipam/forms/model_forms.py:601
+msgid "Child VLANs"
+msgstr ""
+
+#: ipam/forms/model_forms.py:670 ipam/forms/model_forms.py:704
+msgid ""
+"Comma-separated list of one or more port numbers. A range may be specified "
+"using a hyphen."
+msgstr ""
+
+#: ipam/forms/model_forms.py:675 templates/ipam/servicetemplate.html:12
+msgid "Service Template"
+msgstr ""
+
+#: ipam/forms/model_forms.py:726
+msgid "Service template"
+msgstr ""
+
+#: ipam/forms/model_forms.py:846
+msgid "A termination must specify an interface or VLAN."
+msgstr ""
+
+#: ipam/forms/model_forms.py:848
+msgid ""
+"A termination can only have one terminating object (an interface or VLAN)."
+msgstr ""
+
+#: ipam/models/asns.py:34
+msgid "start"
+msgstr ""
+
+#: ipam/models/asns.py:51
+msgid "ASN range"
+msgstr ""
+
+#: ipam/models/asns.py:52
+msgid "ASN ranges"
+msgstr ""
+
+#: ipam/models/asns.py:72
+#, python-brace-format
+msgid "Starting ASN ({start}) must be lower than ending ASN ({end})."
+msgstr ""
+
+#: ipam/models/asns.py:104
+msgid "Regional Internet Registry responsible for this AS number space"
+msgstr ""
+
+#: ipam/models/asns.py:109
+msgid "16- or 32-bit autonomous system number"
+msgstr ""
+
+#: ipam/models/fhrp.py:23
+msgid "group ID"
+msgstr ""
+
+#: ipam/models/fhrp.py:31 ipam/models/services.py:22
+msgid "protocol"
+msgstr ""
+
+#: ipam/models/fhrp.py:39 wireless/models.py:27
+msgid "authentication type"
+msgstr ""
+
+#: ipam/models/fhrp.py:44
+msgid "authentication key"
+msgstr ""
+
+#: ipam/models/fhrp.py:57
+msgid "FHRP group"
+msgstr ""
+
+#: ipam/models/fhrp.py:58
+msgid "FHRP groups"
+msgstr ""
+
+#: ipam/models/fhrp.py:94 tenancy/models/contacts.py:133
+msgid "priority"
+msgstr ""
+
+#: ipam/models/fhrp.py:111
+msgid "FHRP group assignment"
+msgstr ""
+
+#: ipam/models/fhrp.py:112
+msgid "FHRP group assignments"
+msgstr ""
+
+#: ipam/models/ip.py:64
+msgid "private"
+msgstr ""
+
+#: ipam/models/ip.py:65
+msgid "IP space managed by this RIR is considered private"
+msgstr ""
+
+#: ipam/models/ip.py:71 netbox/navigation/menu.py:170
+msgid "RIRs"
+msgstr ""
+
+#: ipam/models/ip.py:83
+msgid "IPv4 or IPv6 network"
+msgstr ""
+
+#: ipam/models/ip.py:90
+msgid "Regional Internet Registry responsible for this IP space"
+msgstr ""
+
+#: ipam/models/ip.py:100
+msgid "date added"
+msgstr ""
+
+#: ipam/models/ip.py:114
+msgid "aggregate"
+msgstr ""
+
+#: ipam/models/ip.py:115
+msgid "aggregates"
+msgstr ""
+
+#: ipam/models/ip.py:131
+msgid "Cannot create aggregate with /0 mask."
+msgstr ""
+
+#: ipam/models/ip.py:143
+#, python-brace-format
+msgid ""
+"Aggregates cannot overlap. {prefix} is already covered by an existing "
+"aggregate ({aggregate})."
+msgstr ""
+
+#: ipam/models/ip.py:157
+#, python-brace-format
+msgid ""
+"Prefixes cannot overlap aggregates. {prefix} covers an existing aggregate "
+"({aggregate})."
+msgstr ""
+
+#: ipam/models/ip.py:199 ipam/models/ip.py:736
+msgid "role"
+msgstr ""
+
+#: ipam/models/ip.py:200
+msgid "roles"
+msgstr ""
+
+#: ipam/models/ip.py:216 ipam/models/ip.py:292
+msgid "prefix"
+msgstr ""
+
+#: ipam/models/ip.py:217
+msgid "IPv4 or IPv6 network with mask"
+msgstr ""
+
+#: ipam/models/ip.py:253
+msgid "Operational status of this prefix"
+msgstr ""
+
+#: ipam/models/ip.py:261
+msgid "The primary function of this prefix"
+msgstr ""
+
+#: ipam/models/ip.py:264
+msgid "is a pool"
+msgstr ""
+
+#: ipam/models/ip.py:266
+msgid "All IP addresses within this prefix are considered usable"
+msgstr ""
+
+#: ipam/models/ip.py:269 ipam/models/ip.py:536
+msgid "mark utilized"
+msgstr ""
+
+#: ipam/models/ip.py:293
+msgid "prefixes"
+msgstr ""
+
+#: ipam/models/ip.py:316
+msgid "Cannot create prefix with /0 mask."
+msgstr ""
+
+#: ipam/models/ip.py:323 ipam/models/ip.py:853
+#, python-brace-format
+msgid "VRF {vrf}"
+msgstr ""
+
+#: ipam/models/ip.py:323 ipam/models/ip.py:853
+msgid "global table"
+msgstr ""
+
+#: ipam/models/ip.py:325
+#, python-brace-format
+msgid "Duplicate prefix found in {table}: {prefix}"
+msgstr ""
+
+#: ipam/models/ip.py:494
+msgid "start address"
+msgstr ""
+
+#: ipam/models/ip.py:495 ipam/models/ip.py:499 ipam/models/ip.py:711
+msgid "IPv4 or IPv6 address (with mask)"
+msgstr ""
+
+#: ipam/models/ip.py:498
+msgid "end address"
+msgstr ""
+
+#: ipam/models/ip.py:525
+msgid "Operational status of this range"
+msgstr ""
+
+#: ipam/models/ip.py:533
+msgid "The primary function of this range"
+msgstr ""
+
+#: ipam/models/ip.py:547
+msgid "IP range"
+msgstr ""
+
+#: ipam/models/ip.py:548
+msgid "IP ranges"
+msgstr ""
+
+#: ipam/models/ip.py:564
+msgid "Starting and ending IP address versions must match"
+msgstr ""
+
+#: ipam/models/ip.py:570
+msgid "Starting and ending IP address masks must match"
+msgstr ""
+
+#: ipam/models/ip.py:577
+#, python-brace-format
+msgid ""
+"Ending address must be lower than the starting address ({start_address})"
+msgstr ""
+
+#: ipam/models/ip.py:589
+#, python-brace-format
+msgid "Defined addresses overlap with range {overlapping_range} in VRF {vrf}"
+msgstr ""
+
+#: ipam/models/ip.py:598
+#, python-brace-format
+msgid "Defined range exceeds maximum supported size ({max_size})"
+msgstr ""
+
+#: ipam/models/ip.py:710 tenancy/models/contacts.py:81
+msgid "address"
+msgstr ""
+
+#: ipam/models/ip.py:733
+msgid "The operational status of this IP"
+msgstr ""
+
+#: ipam/models/ip.py:740
+msgid "The functional role of this IP"
+msgstr ""
+
+#: ipam/models/ip.py:764 templates/ipam/ipaddress.html:75
+msgid "NAT (inside)"
+msgstr ""
+
+#: ipam/models/ip.py:765
+msgid "The IP for which this address is the \"outside\" IP"
+msgstr ""
+
+#: ipam/models/ip.py:772
+msgid "Hostname or FQDN (not case-sensitive)"
+msgstr ""
+
+#: ipam/models/ip.py:787 ipam/models/services.py:94
+msgid "IP addresses"
+msgstr ""
+
+#: ipam/models/ip.py:843
+msgid "Cannot create IP address with /0 mask."
+msgstr ""
+
+#: ipam/models/ip.py:855
+#, python-brace-format
+msgid "Duplicate IP address found in {table}: {ipaddress}"
+msgstr ""
+
+#: ipam/models/ip.py:884
+msgid "Only IPv6 addresses can be assigned SLAAC status"
+msgstr ""
+
+#: ipam/models/l2vpn.py:64 netbox/navigation/menu.py:205
+msgid "L2VPNs"
+msgstr ""
+
+#: ipam/models/l2vpn.py:113
+msgid "L2VPN termination"
+msgstr ""
+
+#: ipam/models/l2vpn.py:114
+msgid "L2VPN terminations"
+msgstr ""
+
+#: ipam/models/l2vpn.py:132
+#, python-brace-format
+msgid "L2VPN Termination already assigned ({assigned_object})"
+msgstr ""
+
+#: ipam/models/l2vpn.py:144
+#, python-brace-format
+msgid ""
+"{l2vpn_type} L2VPNs cannot have more than two terminations; found "
+"{terminations_count} already defined."
+msgstr ""
+
+#: ipam/models/services.py:33
+msgid "port numbers"
+msgstr ""
+
+#: ipam/models/services.py:59
+msgid "service template"
+msgstr ""
+
+#: ipam/models/services.py:60
+msgid "service templates"
+msgstr ""
+
+#: ipam/models/services.py:95
+msgid "The specific IP addresses (if any) to which this service is bound"
+msgstr ""
+
+#: ipam/models/services.py:102
+msgid "service"
+msgstr ""
+
+#: ipam/models/services.py:103
+msgid "services"
+msgstr ""
+
+#: ipam/models/services.py:117
+msgid ""
+"A service cannot be associated with both a device and a virtual machine."
+msgstr ""
+
+#: ipam/models/services.py:119
+msgid "A service must be associated with either a device or a virtual machine."
+msgstr ""
+
+#: ipam/models/vlans.py:50
+msgid "minimum VLAN ID"
+msgstr ""
+
+#: ipam/models/vlans.py:56
+msgid "Lowest permissible ID of a child VLAN"
+msgstr ""
+
+#: ipam/models/vlans.py:59
+msgid "maximum VLAN ID"
+msgstr ""
+
+#: ipam/models/vlans.py:65
+msgid "Highest permissible ID of a child VLAN"
+msgstr ""
+
+#: ipam/models/vlans.py:83
+msgid "VLAN groups"
+msgstr ""
+
+#: ipam/models/vlans.py:93
+msgid "Cannot set scope_type without scope_id."
+msgstr ""
+
+#: ipam/models/vlans.py:95
+msgid "Cannot set scope_id without scope_type."
+msgstr ""
+
+#: ipam/models/vlans.py:100
+msgid "Maximum child VID must be greater than or equal to minimum child VID"
+msgstr ""
+
+#: ipam/models/vlans.py:143
+msgid "The specific site to which this VLAN is assigned (if any)"
+msgstr ""
+
+#: ipam/models/vlans.py:151
+msgid "VLAN group (optional)"
+msgstr ""
+
+#: ipam/models/vlans.py:159
+msgid "Numeric VLAN ID (1-4094)"
+msgstr ""
+
+#: ipam/models/vlans.py:177
+msgid "Operational status of this VLAN"
+msgstr ""
+
+#: ipam/models/vlans.py:185
+msgid "The primary function of this VLAN"
+msgstr ""
+
+#: ipam/models/vlans.py:214 ipam/tables/ip.py:175 ipam/tables/vlans.py:78
+#: ipam/views.py:942 netbox/navigation/menu.py:181
+#: netbox/navigation/menu.py:183
+msgid "VLANs"
+msgstr ""
+
+#: ipam/models/vlans.py:229
+#, python-brace-format
+msgid ""
+"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to "
+"site {site}."
+msgstr ""
+
+#: ipam/models/vlans.py:237
+#, python-brace-format
+msgid "VID must be between {minimum} and {maximum} for VLANs in group {group}"
+msgstr ""
+
+#: ipam/models/vrfs.py:30
+msgid "route distinguisher"
+msgstr ""
+
+#: ipam/models/vrfs.py:31
+msgid "Unique route distinguisher (as defined in RFC 4364)"
+msgstr ""
+
+#: ipam/models/vrfs.py:42
+msgid "enforce unique space"
+msgstr ""
+
+#: ipam/models/vrfs.py:43
+msgid "Prevent duplicate prefixes/IP addresses within this VRF"
+msgstr ""
+
+#: ipam/models/vrfs.py:63 netbox/navigation/menu.py:174
+#: netbox/navigation/menu.py:176
+msgid "VRFs"
+msgstr ""
+
+#: ipam/models/vrfs.py:82
+msgid "Route target value (formatted in accordance with RFC 4360)"
+msgstr ""
+
+#: ipam/models/vrfs.py:94
+msgid "route target"
+msgstr ""
+
+#: ipam/models/vrfs.py:95
+msgid "route targets"
+msgstr ""
+
+#: ipam/tables/asn.py:51
+msgid "ASDOT"
+msgstr ""
+
+#: ipam/tables/asn.py:56
+msgid "Site Count"
+msgstr ""
+
+#: ipam/tables/asn.py:61
+msgid "Provider Count"
+msgstr ""
+
+#: ipam/tables/ip.py:94 netbox/navigation/menu.py:167
+#: netbox/navigation/menu.py:169
+msgid "Aggregates"
+msgstr ""
+
+#: ipam/tables/ip.py:124
+msgid "Added"
+msgstr ""
+
+#: ipam/tables/ip.py:127 ipam/tables/ip.py:165 ipam/tables/vlans.py:138
+#: ipam/views.py:351 netbox/navigation/menu.py:153
+#: netbox/navigation/menu.py:155 templates/ipam/vlan.html:87
+msgid "Prefixes"
+msgstr ""
+
+#: ipam/tables/ip.py:130 ipam/tables/ip.py:267 ipam/tables/ip.py:320
+#: ipam/tables/vlans.py:82 templates/dcim/device.html:280
+#: templates/ipam/aggregate.html:25 templates/ipam/iprange.html:32
+#: templates/ipam/prefix.html:100
+msgid "Utilization"
+msgstr ""
+
+#: ipam/tables/ip.py:170 netbox/navigation/menu.py:149
+msgid "IP Ranges"
+msgstr ""
+
+#: ipam/tables/ip.py:220
+msgid "Prefix (Flat)"
+msgstr ""
+
+#: ipam/tables/ip.py:224 templates/dcim/rack_edit.html:52
+msgid "Depth"
+msgstr ""
+
+#: ipam/tables/ip.py:233
+msgid "Children"
+msgstr ""
+
+#: ipam/tables/ip.py:261
+msgid "Pool"
+msgstr ""
+
+#: ipam/tables/ip.py:264 ipam/tables/ip.py:317
+msgid "Marked Utilized"
+msgstr ""
+
+#: ipam/tables/ip.py:301
+msgid "Start address"
+msgstr ""
+
+#: ipam/tables/ip.py:379
+msgid "NAT (Inside)"
+msgstr ""
+
+#: ipam/tables/ip.py:384
+msgid "NAT (Outside)"
+msgstr ""
+
+#: ipam/tables/ip.py:389
+msgid "Assigned"
+msgstr ""
+
+#: ipam/tables/l2vpn.py:27 ipam/tables/vrfs.py:36
+msgid "Import Targets"
+msgstr ""
+
+#: ipam/tables/l2vpn.py:32 ipam/tables/vrfs.py:41
+msgid "Export Targets"
+msgstr ""
+
+#: ipam/tables/l2vpn.py:69
+msgid "Object Parent"
+msgstr ""
+
+#: ipam/tables/l2vpn.py:74
+msgid "Object Site"
+msgstr ""
+
+#: ipam/tables/vlans.py:68
+msgid "Scope Type"
+msgstr ""
+
+#: ipam/tables/vlans.py:107 ipam/tables/vlans.py:210
+#: templates/dcim/inc/interface_vlans_table.html:4
+msgid "VID"
+msgstr ""
+
+#: ipam/tables/vrfs.py:30
+msgid "RD"
+msgstr ""
+
+#: ipam/tables/vrfs.py:33
+msgid "Unique"
+msgstr ""
+
+#: ipam/views.py:538
+msgid "Child Prefixes"
+msgstr ""
+
+#: ipam/views.py:573
+msgid "Child Ranges"
+msgstr ""
+
+#: ipam/views.py:870
+msgid "Related IPs"
+msgstr ""
+
+#: ipam/views.py:1093
+msgid "Device Interfaces"
+msgstr ""
+
+#: ipam/views.py:1111
+msgid "VM Interfaces"
+msgstr ""
+
+#: netbox/config/parameters.py:22 templates/extras/configrevision.html:111
+msgid "Login banner"
+msgstr ""
+
+#: netbox/config/parameters.py:24
+msgid "Additional content to display on the login page"
+msgstr ""
+
+#: netbox/config/parameters.py:33 templates/extras/configrevision.html:115
+msgid "Maintenance banner"
+msgstr ""
+
+#: netbox/config/parameters.py:35
+msgid "Additional content to display when in maintenance mode"
+msgstr ""
+
+#: netbox/config/parameters.py:44 templates/extras/configrevision.html:119
+msgid "Top banner"
+msgstr ""
+
+#: netbox/config/parameters.py:46
+msgid "Additional content to display at the top of every page"
+msgstr ""
+
+#: netbox/config/parameters.py:55 templates/extras/configrevision.html:123
+msgid "Bottom banner"
+msgstr ""
+
+#: netbox/config/parameters.py:57
+msgid "Additional content to display at the bottom of every page"
+msgstr ""
+
+#: netbox/config/parameters.py:68
+msgid "Globally unique IP space"
+msgstr ""
+
+#: netbox/config/parameters.py:70
+msgid "Enforce unique IP addressing within the global table"
+msgstr ""
+
+#: netbox/config/parameters.py:75 templates/extras/configrevision.html:87
+msgid "Prefer IPv4"
+msgstr ""
+
+#: netbox/config/parameters.py:77
+msgid "Prefer IPv4 addresses over IPv6"
+msgstr ""
+
+#: netbox/config/parameters.py:84
+msgid "Rack unit height"
+msgstr ""
+
+#: netbox/config/parameters.py:86
+msgid "Default unit height for rendered rack elevations"
+msgstr ""
+
+#: netbox/config/parameters.py:91
+msgid "Rack unit width"
+msgstr ""
+
+#: netbox/config/parameters.py:93
+msgid "Default unit width for rendered rack elevations"
+msgstr ""
+
+#: netbox/config/parameters.py:100
+msgid "Powerfeed voltage"
+msgstr ""
+
+#: netbox/config/parameters.py:102
+msgid "Default voltage for powerfeeds"
+msgstr ""
+
+#: netbox/config/parameters.py:107
+msgid "Powerfeed amperage"
+msgstr ""
+
+#: netbox/config/parameters.py:109
+msgid "Default amperage for powerfeeds"
+msgstr ""
+
+#: netbox/config/parameters.py:114
+msgid "Powerfeed max utilization"
+msgstr ""
+
+#: netbox/config/parameters.py:116
+msgid "Default max utilization for powerfeeds"
+msgstr ""
+
+#: netbox/config/parameters.py:123 templates/extras/configrevision.html:99
+msgid "Allowed URL schemes"
+msgstr ""
+
+#: netbox/config/parameters.py:128
+msgid "Permitted schemes for URLs in user-provided content"
+msgstr ""
+
+#: netbox/config/parameters.py:136
+msgid "Default page size"
+msgstr ""
+
+#: netbox/config/parameters.py:142
+msgid "Maximum page size"
+msgstr ""
+
+#: netbox/config/parameters.py:150 templates/extras/configrevision.html:151
+msgid "Custom validators"
+msgstr ""
+
+#: netbox/config/parameters.py:152
+msgid "Custom validation rules (JSON)"
+msgstr ""
+
+#: netbox/config/parameters.py:164
+msgid "Default preferences"
+msgstr ""
+
+#: netbox/config/parameters.py:166
+msgid "Default preferences for new users"
+msgstr ""
+
+#: netbox/config/parameters.py:173 templates/extras/configrevision.html:175
+msgid "Maintenance mode"
+msgstr ""
+
+#: netbox/config/parameters.py:175
+msgid "Enable maintenance mode"
+msgstr ""
+
+#: netbox/config/parameters.py:180 templates/extras/configrevision.html:179
+msgid "GraphQL enabled"
+msgstr ""
+
+#: netbox/config/parameters.py:182
+msgid "Enable the GraphQL API"
+msgstr ""
+
+#: netbox/config/parameters.py:187 templates/extras/configrevision.html:183
+msgid "Changelog retention"
+msgstr ""
+
+#: netbox/config/parameters.py:189
+msgid "Days to retain changelog history (set to zero for unlimited)"
+msgstr ""
+
+#: netbox/config/parameters.py:194
+msgid "Job result retention"
+msgstr ""
+
+#: netbox/config/parameters.py:196
+msgid "Days to retain job result history (set to zero for unlimited)"
+msgstr ""
+
+#: netbox/config/parameters.py:201 templates/extras/configrevision.html:191
+msgid "Maps URL"
+msgstr ""
+
+#: netbox/config/parameters.py:203
+msgid "Base URL for mapping geographic locations"
+msgstr ""
+
+#: netbox/forms/__init__.py:13
+msgid "Partial match"
+msgstr ""
+
+#: netbox/forms/__init__.py:14
+msgid "Exact match"
+msgstr ""
+
+#: netbox/forms/__init__.py:15
+msgid "Starts with"
+msgstr ""
+
+#: netbox/forms/__init__.py:16
+msgid "Ends with"
+msgstr ""
+
+#: netbox/forms/__init__.py:17
+msgid "Regex"
+msgstr ""
+
+#: netbox/forms/__init__.py:35
+msgid "Object type(s)"
+msgstr ""
+
+#: netbox/forms/base.py:66
+msgid "Id"
+msgstr ""
+
+#: netbox/forms/base.py:107
+msgid "Add tags"
+msgstr ""
+
+#: netbox/forms/base.py:112
+msgid "Remove tags"
+msgstr ""
+
+#: netbox/models/features.py:422
+msgid "Remote data source"
+msgstr ""
+
+#: netbox/models/features.py:432
+msgid "data path"
+msgstr ""
+
+#: netbox/models/features.py:436
+msgid "Path to remote file (relative to data source root)"
+msgstr ""
+
+#: netbox/models/features.py:439
+msgid "auto sync enabled"
+msgstr ""
+
+#: netbox/models/features.py:441
+msgid "Enable automatic synchronization of data when the data file is updated"
+msgstr ""
+
+#: netbox/models/features.py:444
+msgid "date synced"
+msgstr ""
+
+#: netbox/navigation/menu.py:12
+msgid "Organization"
+msgstr ""
+
+#: netbox/navigation/menu.py:20
+msgid "Site Groups"
+msgstr ""
+
+#: netbox/navigation/menu.py:28
+msgid "Rack Roles"
+msgstr ""
+
+#: netbox/navigation/menu.py:32
+msgid "Elevations"
+msgstr ""
+
+#: netbox/navigation/menu.py:41
+msgid "Tenant Groups"
+msgstr ""
+
+#: netbox/navigation/menu.py:48
+msgid "Contact Groups"
+msgstr ""
+
+#: netbox/navigation/menu.py:49 templates/tenancy/contactrole.html:8
+msgid "Contact Roles"
+msgstr ""
+
+#: netbox/navigation/menu.py:50
+msgid "Contact Assignments"
+msgstr ""
+
+#: netbox/navigation/menu.py:64
+msgid "Modules"
+msgstr ""
+
+#: netbox/navigation/menu.py:65 templates/dcim/devicerole.html:8
+msgid "Device Roles"
+msgstr ""
+
+#: netbox/navigation/menu.py:68 templates/dcim/device.html:179
+#: templates/dcim/virtualdevicecontext.html:8
+msgid "Virtual Device Contexts"
+msgstr ""
+
+#: netbox/navigation/menu.py:76
+msgid "Manufacturers"
+msgstr ""
+
+#: netbox/navigation/menu.py:80
+msgid "Device Components"
+msgstr ""
+
+#: netbox/navigation/menu.py:92 templates/dcim/inventoryitemrole.html:8
+msgid "Inventory Item Roles"
+msgstr ""
+
+#: netbox/navigation/menu.py:99 netbox/navigation/menu.py:103
+msgid "Connections"
+msgstr ""
+
+#: netbox/navigation/menu.py:105
+msgid "Cables"
+msgstr ""
+
+#: netbox/navigation/menu.py:106
+msgid "Wireless Links"
+msgstr ""
+
+#: netbox/navigation/menu.py:109
+msgid "Interface Connections"
+msgstr ""
+
+#: netbox/navigation/menu.py:114
+msgid "Console Connections"
+msgstr ""
+
+#: netbox/navigation/menu.py:119
+msgid "Power Connections"
+msgstr ""
+
+#: netbox/navigation/menu.py:135
+msgid "Wireless LAN Groups"
+msgstr ""
+
+#: netbox/navigation/menu.py:156
+msgid "Prefix & VLAN Roles"
+msgstr ""
+
+#: netbox/navigation/menu.py:162
+msgid "ASN Ranges"
+msgstr ""
+
+#: netbox/navigation/menu.py:184
+msgid "VLAN Groups"
+msgstr ""
+
+#: netbox/navigation/menu.py:191
+msgid "Service Templates"
+msgstr ""
+
+#: netbox/navigation/menu.py:192 templates/dcim/device.html:321
+#: templates/ipam/ipaddress.html:122
+#: templates/virtualization/virtualmachine.html:155
+msgid "Services"
+msgstr ""
+
+#: netbox/navigation/menu.py:199
+msgid "Overlay"
+msgstr ""
+
+#: netbox/navigation/menu.py:206 templates/ipam/l2vpn.html:57
+msgid "Terminations"
+msgstr ""
+
+#: netbox/navigation/menu.py:213 templates/dcim/device_edit.html:78
+msgid "Virtualization"
+msgstr ""
+
+#: netbox/navigation/menu.py:217 netbox/navigation/menu.py:219
+#: virtualization/views.py:186
+msgid "Virtual Machines"
+msgstr ""
+
+#: netbox/navigation/menu.py:227
+msgid "Cluster Types"
+msgstr ""
+
+#: netbox/navigation/menu.py:228
+msgid "Cluster Groups"
+msgstr ""
+
+#: netbox/navigation/menu.py:242
+msgid "Circuit Types"
+msgstr ""
+
+#: netbox/navigation/menu.py:246 netbox/navigation/menu.py:248
+msgid "Providers"
+msgstr ""
+
+#: netbox/navigation/menu.py:249 templates/circuits/provider.html:53
+msgid "Provider Accounts"
+msgstr ""
+
+#: netbox/navigation/menu.py:250
+msgid "Provider Networks"
+msgstr ""
+
+#: netbox/navigation/menu.py:264
+msgid "Power Panels"
+msgstr ""
+
+#: netbox/navigation/menu.py:275
+msgid "Configurations"
+msgstr ""
+
+#: netbox/navigation/menu.py:277
+msgid "Config Contexts"
+msgstr ""
+
+#: netbox/navigation/menu.py:278
+msgid "Config Templates"
+msgstr ""
+
+#: netbox/navigation/menu.py:285 netbox/navigation/menu.py:289
+msgid "Customization"
+msgstr ""
+
+#: netbox/navigation/menu.py:291
+#: templates/circuits/circuittermination_edit.html:53
+#: templates/dcim/cable_edit.html:77 templates/dcim/device_edit.html:103
+#: templates/dcim/inventoryitem_edit.html:102 templates/dcim/rack_edit.html:81
+#: templates/dcim/virtualchassis_add.html:31
+#: templates/dcim/virtualchassis_edit.html:41
+#: templates/generic/bulk_edit.html:92 templates/htmx/form.html:32
+#: templates/inc/panels/custom_fields.html:7
+#: templates/ipam/ipaddress_bulk_add.html:35
+#: templates/ipam/ipaddress_edit.html:88
+#: templates/ipam/l2vpntermination_edit.html:51
+#: templates/ipam/service_create.html:75 templates/ipam/service_edit.html:62
+#: templates/ipam/vlan_edit.html:63
+msgid "Custom Fields"
+msgstr ""
+
+#: netbox/navigation/menu.py:292
+msgid "Custom Field Choices"
+msgstr ""
+
+#: netbox/navigation/menu.py:293
+msgid "Custom Links"
+msgstr ""
+
+#: netbox/navigation/menu.py:294
+msgid "Export Templates"
+msgstr ""
+
+#: netbox/navigation/menu.py:295
+msgid "Saved Filters"
+msgstr ""
+
+#: netbox/navigation/menu.py:297
+msgid "Image Attachments"
+msgstr ""
+
+#: netbox/navigation/menu.py:301
+msgid "Reports & Scripts"
+msgstr ""
+
+#: netbox/navigation/menu.py:321
+msgid "Operations"
+msgstr ""
+
+#: netbox/navigation/menu.py:325
+msgid "Integrations"
+msgstr ""
+
+#: netbox/navigation/menu.py:327
+msgid "Data Sources"
+msgstr ""
+
+#: netbox/navigation/menu.py:328
+msgid "Webhooks"
+msgstr ""
+
+#: netbox/navigation/menu.py:332 netbox/navigation/menu.py:336
+#: netbox/views/generic/feature_views.py:151
+#: templates/extras/report/base.html:37 templates/extras/script/base.html:36
+msgid "Jobs"
+msgstr ""
+
+#: netbox/navigation/menu.py:342
+msgid "Logging"
+msgstr ""
+
+#: netbox/navigation/menu.py:344
+msgid "Journal Entries"
+msgstr ""
+
+#: netbox/navigation/menu.py:345 templates/extras/objectchange.html:8
+#: templates/extras/objectchange_list.html:4
+msgid "Change Log"
+msgstr ""
+
+#: netbox/navigation/menu.py:352 templates/inc/profile_button.html:18
+msgid "Admin"
+msgstr ""
+
+#: netbox/navigation/menu.py:361 templates/users/group.html:27
+#: users/forms/model_forms.py:242 users/forms/model_forms.py:255
+#: users/forms/model_forms.py:309 users/tables.py:105
+msgid "Users"
+msgstr ""
+
+#: netbox/navigation/menu.py:384 users/forms/model_forms.py:182
+#: users/forms/model_forms.py:195 users/forms/model_forms.py:314
+#: users/tables.py:35 users/tables.py:109
+msgid "Groups"
+msgstr ""
+
+#: netbox/navigation/menu.py:406 templates/account/base.html:21
+#: templates/inc/profile_button.html:39
+msgid "API Tokens"
+msgstr ""
+
+#: netbox/navigation/menu.py:413 users/forms/model_forms.py:188
+#: users/forms/model_forms.py:197 users/forms/model_forms.py:248
+#: users/forms/model_forms.py:256
+msgid "Permissions"
+msgstr ""
+
+#: netbox/navigation/menu.py:425
+msgid "Current Config"
+msgstr ""
+
+#: netbox/navigation/menu.py:431
+msgid "Config Revisions"
+msgstr ""
+
+#: netbox/navigation/menu.py:471 templates/500.html:35
+#: templates/account/preferences.html:29
+msgid "Plugins"
+msgstr ""
+
+#: netbox/preferences.py:17
+msgid "Color mode"
+msgstr ""
+
+#: netbox/preferences.py:25
+msgid "Page length"
+msgstr ""
+
+#: netbox/preferences.py:27
+msgid "The default number of objects to display per page"
+msgstr ""
+
+#: netbox/preferences.py:31
+msgid "Paginator placement"
+msgstr ""
+
+#: netbox/preferences.py:37
+msgid "Where the paginator controls will be displayed relative to a table"
+msgstr ""
+
+#: netbox/preferences.py:43
+msgid "Data format"
+msgstr ""
+
+#: netbox/tables/columns.py:175
+msgid "Toggle all"
+msgstr ""
+
+#: netbox/tables/columns.py:277 templates/inc/profile_button.html:56
+msgid "Toggle Dropdown"
+msgstr ""
+
+#: netbox/tables/columns.py:542
+msgid "Error"
+msgstr ""
+
+#: netbox/tables/tables.py:234 templates/generic/bulk_import.html:115
+msgid "Field"
+msgstr ""
+
+#: netbox/tables/tables.py:237
+msgid "Value"
+msgstr ""
+
+#: netbox/tables/tables.py:246
+msgid "No results found"
+msgstr ""
+
+#: netbox/tests/dummy_plugin/navigation.py:29
+msgid "Dummy Plugin"
+msgstr ""
+
+#: netbox/views/generic/feature_views.py:38
+msgid "Changelog"
+msgstr ""
+
+#: netbox/views/generic/feature_views.py:91
+msgid "Journal"
+msgstr ""
+
+#: templates/403.html:4
+msgid "Access Denied"
+msgstr ""
+
+#: templates/403.html:9
+msgid "You do not have permission to access this page"
+msgstr ""
+
+#: templates/404.html:4
+msgid "Page Not Found"
+msgstr ""
+
+#: templates/404.html:9
+msgid "The requested page does not exist"
+msgstr ""
+
+#: templates/500.html:7 templates/500.html:18
+msgid "Server Error"
+msgstr ""
+
+#: templates/500.html:23
+msgid "There was a problem with your request. Please contact an administrator"
+msgstr ""
+
+#: templates/500.html:28
+msgid "The complete exception is provided below"
+msgstr ""
+
+#: templates/500.html:33
+msgid "Python version"
+msgstr ""
+
+#: templates/500.html:34
+msgid "NetBox version"
+msgstr ""
+
+#: templates/500.html:36
+msgid "None installed"
+msgstr ""
+
+#: templates/500.html:39
+msgid "If further assistance is required, please post to the"
+msgstr ""
+
+#: templates/500.html:39
+msgid "NetBox discussion forum"
+msgstr ""
+
+#: templates/500.html:39
+msgid "on GitHub"
+msgstr ""
+
+#: templates/500.html:42 templates/base/40x.html:17
+msgid "Home Page"
+msgstr ""
+
+#: templates/account/base.html:7 templates/inc/profile_button.html:24
+msgid "Profile"
+msgstr ""
+
+#: templates/account/base.html:13 templates/inc/profile_button.html:34
+msgid "Preferences"
+msgstr ""
+
+#: templates/account/password.html:5
+msgid "Change Password"
+msgstr ""
+
+#: templates/account/password.html:17 templates/account/preferences.html:82
+#: templates/dcim/devicebay_populate.html:34
+#: templates/dcim/virtualchassis_add_member.html:24
+#: templates/dcim/virtualchassis_edit.html:104
+#: templates/extras/configrevision_restore.html:80
+#: templates/extras/object_journal.html:26 templates/extras/script.html:36
+#: templates/generic/bulk_add_component.html:55
+#: templates/generic/bulk_delete.html:46 templates/generic/bulk_edit.html:125
+#: templates/generic/bulk_import.html:53 templates/generic/bulk_import.html:75
+#: templates/generic/bulk_import.html:97 templates/generic/bulk_remove.html:42
+#: templates/generic/bulk_rename.html:44
+#: templates/generic/confirmation_form.html:20
+#: templates/generic/object_edit.html:76 templates/htmx/delete_form.html:19
+#: templates/htmx/delete_form.html:21 templates/ipam/ipaddress_assign.html:31
+#: templates/virtualization/cluster_add_devices.html:30
+msgid "Cancel"
+msgstr ""
+
+#: templates/account/password.html:18 templates/account/preferences.html:83
+#: templates/dcim/devicebay_populate.html:35
+#: templates/dcim/virtualchassis_add_member.html:26
+#: templates/dcim/virtualchassis_edit.html:106
+#: templates/extras/dashboard/widget_add.html:26
+#: templates/extras/dashboard/widget_config.html:19
+#: templates/extras/object_journal.html:27
+#: templates/generic/object_edit.html:66
+#: utilities/templates/helpers/applied_filters.html:16
+#: utilities/templates/helpers/table_config_form.html:40
+msgid "Save"
+msgstr ""
+
+#: templates/account/preferences.html:41
+msgid "Table Configurations"
+msgstr ""
+
+#: templates/account/preferences.html:46
+msgid "Clear table preferences"
+msgstr ""
+
+#: templates/account/preferences.html:53
+msgid "Toggle All"
+msgstr ""
+
+#: templates/account/preferences.html:55
+msgid "Table"
+msgstr ""
+
+#: templates/account/preferences.html:56
+msgid "Ordering"
+msgstr ""
+
+#: templates/account/preferences.html:57
+msgid "Columns"
+msgstr ""
+
+#: templates/account/preferences.html:76 templates/dcim/cable_trace.html:113
+#: templates/extras/object_configcontext.html:55
+msgid "None found"
+msgstr ""
+
+#: templates/account/profile.html:6
+msgid "User Profile"
+msgstr ""
+
+#: templates/account/profile.html:12
+msgid "Account Details"
+msgstr ""
+
+#: templates/account/profile.html:30 templates/tenancy/contact.html:44
+#: templates/users/user.html:26 tenancy/forms/bulk_edit.py:108
+msgid "Email"
+msgstr ""
+
+#: templates/account/profile.html:34 templates/users/user.html:30
+msgid "Account Created"
+msgstr ""
+
+#: templates/account/profile.html:38 templates/users/user.html:42
+msgid "Superuser"
+msgstr ""
+
+#: templates/account/profile.html:42
+msgid "Admin Access"
+msgstr ""
+
+#: templates/account/profile.html:51 templates/users/objectpermission.html:86
+#: templates/users/user.html:51
+msgid "Assigned Groups"
+msgstr ""
+
+#: templates/account/profile.html:56
+#: templates/circuits/circuit_terminations_swap.html:18
+#: templates/circuits/circuit_terminations_swap.html:26
+#: templates/circuits/inc/circuit_termination.html:154
+#: templates/dcim/devicebay.html:66
+#: templates/dcim/inc/panels/inventory_items.html:37
+#: templates/dcim/interface.html:302 templates/dcim/modulebay.html:79
+#: templates/extras/configcontext.html:73
+#: templates/extras/htmx/script_result.html:54
+#: templates/extras/object_configcontext.html:28
+#: templates/extras/objectchange.html:128
+#: templates/extras/objectchange.html:145 templates/extras/webhook.html:122
+#: templates/extras/webhook.html:134 templates/extras/webhook.html:146
+#: templates/inc/panel_table.html:12 templates/inc/panels/comments.html:12
+#: templates/ipam/inc/panels/fhrp_groups.html:43 templates/users/group.html:32
+#: templates/users/group.html:42 templates/users/objectpermission.html:81
+#: templates/users/objectpermission.html:91 templates/users/user.html:56
+#: templates/users/user.html:66
+msgid "None"
+msgstr ""
+
+#: templates/account/profile.html:66 templates/users/user.html:76
+msgid "Recent Activity"
+msgstr ""
+
+#: templates/account/token.html:8 templates/account/token_list.html:6
+msgid "My API Tokens"
+msgstr ""
+
+#: templates/account/token.html:11 templates/account/token.html:19
+#: templates/users/token.html:6 templates/users/token.html:14
+#: users/forms/filtersets.py:123
+msgid "Token"
+msgstr ""
+
+#: templates/account/token.html:40 templates/users/token.html:32
+#: users/forms/bulk_edit.py:87
+msgid "Write enabled"
+msgstr ""
+
+#: templates/account/token.html:52 templates/users/token.html:44
+msgid "Last used"
+msgstr ""
+
+#: templates/account/token_list.html:12
+msgid "Add a Token"
+msgstr ""
+
+#: templates/admin/index.html:10
+msgid "System"
+msgstr ""
+
+#: templates/admin/index.html:14
+msgid "Background Tasks"
+msgstr ""
+
+#: templates/admin/index.html:19
+msgid "Installed plugins"
+msgstr ""
+
+#: templates/base/base.html:28 templates/extras/admin/plugins_list.html:8
+#: templates/home.html:24
+msgid "Home"
+msgstr ""
+
+#: templates/base/layout.html:27 templates/base/layout.html:37
+#: templates/login.html:34
+msgid "NetBox logo"
+msgstr ""
+
+#: templates/base/layout.html:76
+msgid "Debug mode is enabled"
+msgstr ""
+
+#: templates/base/layout.html:77
+msgid ""
+"Performance may be limited. Debugging should never be enabled on a "
+"production system"
+msgstr ""
+
+#: templates/base/layout.html:83
+msgid "Maintenance Mode"
+msgstr ""
+
+#: templates/base/layout.html:134
+msgid "Docs"
+msgstr ""
+
+#: templates/base/layout.html:139 templates/rest_framework/api.html:10
+msgid "REST API"
+msgstr ""
+
+#: templates/base/layout.html:144
+msgid "REST API documentation"
+msgstr ""
+
+#: templates/base/layout.html:150
+msgid "GraphQL API"
+msgstr ""
+
+#: templates/base/layout.html:156
+msgid "Source Code"
+msgstr ""
+
+#: templates/base/layout.html:161
+msgid "Community"
+msgstr ""
+
+#: templates/base/sidenav.html:12 templates/base/sidenav.html:17
+msgid "NetBox Logo"
+msgstr ""
+
+#: templates/circuits/circuit.html:48
+msgid "Install Date"
+msgstr ""
+
+#: templates/circuits/circuit.html:52
+msgid "Termination Date"
+msgstr ""
+
+#: templates/circuits/circuit_terminations_swap.html:4
+msgid "Swap Circuit Terminations"
+msgstr ""
+
+#: templates/circuits/circuit_terminations_swap.html:8
+#, python-format
+msgid "Swap these terminations for circuit %(circuit)s?"
+msgstr ""
+
+#: templates/circuits/circuit_terminations_swap.html:14
+msgid "A side"
+msgstr ""
+
+#: templates/circuits/circuit_terminations_swap.html:22
+msgid "Z side"
+msgstr ""
+
+#: templates/circuits/circuittermination_edit.html:9
+#: templates/circuits/inc/circuit_termination.html:81
+#: templates/dcim/frontport.html:128 templates/dcim/interface.html:195
+#: templates/dcim/rearport.html:118
+msgid "Circuit Termination"
+msgstr ""
+
+#: templates/circuits/circuittermination_edit.html:41
+msgid "Termination Details"
+msgstr ""
+
+#: templates/circuits/circuittype.html:10
+msgid "Add Circuit"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:9
+#: templates/dcim/devicetype/component_templates.html:30
+#: templates/dcim/manufacturer.html:11
+#: templates/dcim/moduletype/component_templates.html:30
+#: templates/generic/bulk_add_component.html:8
+#: templates/users/objectpermission.html:41
+#: utilities/templates/buttons/add.html:4
+#: utilities/templates/helpers/table_config_form.html:20
+msgid "Add"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:14
+#: templates/circuits/inc/circuit_termination.html:63
+#: templates/dcim/devicetype/component_templates.html:21
+#: templates/dcim/inc/panels/inventory_items.html:24
+#: templates/dcim/moduletype/component_templates.html:21
+#: templates/dcim/powerpanel.html:61 templates/generic/object_edit.html:29
+#: templates/ipam/inc/ipaddress_edit_header.html:10
+#: templates/ipam/inc/panels/fhrp_groups.html:30
+#: utilities/templates/buttons/edit.html:3
+msgid "Edit"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:17
+msgid "Swap"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:26
+#, python-format
+msgid "Termination %(side)s"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:42
+#: templates/dcim/cable.html:70 templates/dcim/cable.html:76
+msgid "Termination"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:46
+#: templates/dcim/consoleport.html:62 templates/dcim/consoleserverport.html:62
+#: templates/dcim/powerfeed.html:122
+msgid "Marked as connected"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:48
+msgid "to"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:58
+#: templates/circuits/inc/circuit_termination.html:59
+#: templates/dcim/frontport.html:87
+#: templates/dcim/inc/connection_endpoints.html:7
+#: templates/dcim/interface.html:156 templates/dcim/rearport.html:83
+msgid "Trace"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:62
+msgid "Edit cable"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:67
+msgid "Remove cable"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:68
+#: templates/dcim/bulk_disconnect.html:5
+#: templates/dcim/device/consoleports.html:12
+#: templates/dcim/device/consoleserverports.html:12
+#: templates/dcim/device/frontports.html:12
+#: templates/dcim/device/interfaces.html:16
+#: templates/dcim/device/poweroutlets.html:12
+#: templates/dcim/device/powerports.html:12
+#: templates/dcim/device/rearports.html:12 templates/dcim/powerpanel.html:66
+msgid "Disconnect"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:75
+#: templates/dcim/consoleport.html:71 templates/dcim/consoleserverport.html:71
+#: templates/dcim/frontport.html:109 templates/dcim/interface.html:182
+#: templates/dcim/interface.html:202 templates/dcim/powerfeed.html:136
+#: templates/dcim/poweroutlet.html:75 templates/dcim/poweroutlet.html:76
+#: templates/dcim/powerport.html:77 templates/dcim/rearport.html:105
+msgid "Connect"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:79
+#: templates/dcim/consoleport.html:78 templates/dcim/consoleserverport.html:78
+#: templates/dcim/frontport.html:18 templates/dcim/frontport.html:122
+#: templates/dcim/interface.html:189 templates/dcim/inventoryitem_edit.html:49
+#: templates/dcim/rearport.html:112
+msgid "Front Port"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:97
+msgid "Downstream"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:98
+msgid "Upstream"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:107
+msgid "Cross-Connect"
+msgstr ""
+
+#: templates/circuits/inc/circuit_termination.html:111
+msgid "Patch Panel/Port"
+msgstr ""
+
+#: templates/circuits/provider.html:11
+msgid "Add circuit"
+msgstr ""
+
+#: templates/circuits/provideraccount.html:17
+msgid "Provider Account"
+msgstr ""
+
+#: templates/core/datafile.html:47
+msgid "Last Updated"
+msgstr ""
+
+#: templates/core/datafile.html:51 templates/ipam/iprange.html:28
+msgid "Size"
+msgstr ""
+
+#: templates/core/datafile.html:52
+msgid "bytes"
+msgstr ""
+
+#: templates/core/datafile.html:55
+msgid "SHA256 Hash"
+msgstr ""
+
+#: templates/core/datasource.html:14 templates/core/datasource.html:20
+#: utilities/templates/buttons/sync.html:5
+msgid "Sync"
+msgstr ""
+
+#: templates/core/datasource.html:51
+msgid "Last synced"
+msgstr ""
+
+#: templates/core/datasource.html:86
+msgid "Backend"
+msgstr ""
+
+#: templates/core/datasource.html:102
+msgid "No parameters defined"
+msgstr ""
+
+#: templates/core/datasource.html:118
+msgid "Files"
+msgstr ""
+
+#: templates/core/job.html:21
+msgid "Job"
+msgstr ""
+
+#: templates/core/job.html:39 templates/extras/journalentry.html:29
+msgid "Created By"
+msgstr ""
+
+#: templates/core/job.html:48
+msgid "Scheduling"
+msgstr ""
+
+#: templates/core/job.html:60
+#, python-format
+msgid "every %(interval)s seconds"
+msgstr ""
+
+#: templates/dcim/bulk_disconnect.html:9
+#, python-format
+msgid ""
+"Are you sure you want to disconnect these %(count)s %(obj_type_plural)s?"
+msgstr ""
+
+#: templates/dcim/cable_edit.html:12
+msgid "A Side"
+msgstr ""
+
+#: templates/dcim/cable_edit.html:29
+msgid "B Side"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:6
+#, python-format
+msgid "Cable Trace for %(object_type)s %(object)s"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:21 templates/dcim/inc/rack_elevation.html:7
+msgid "Download SVG"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:27
+msgid "Asymmetric Path"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:28
+msgid "The nodes below have no links and result in an asymmetric path"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:35
+msgid "Path split"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:36
+msgid "Select a node below to continue"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:52
+msgid "Trace Completed"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:55
+msgid "Total segments"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:59
+msgid "Total length"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:74
+msgid "No paths found"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:83
+msgid "Related Paths"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:89
+msgid "Origin"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:90
+msgid "Destination"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:91
+msgid "Segments"
+msgstr ""
+
+#: templates/dcim/cable_trace.html:104
+msgid "Incomplete"
+msgstr ""
+
+#: templates/dcim/component_list.html:14
+msgid "Rename Selected"
+msgstr ""
+
+#: templates/dcim/consoleport.html:67 templates/dcim/consoleserverport.html:67
+#: templates/dcim/frontport.html:105 templates/dcim/interface.html:178
+#: templates/dcim/poweroutlet.html:73 templates/dcim/powerport.html:73
+msgid "Not Connected"
+msgstr ""
+
+#: templates/dcim/consoleport.html:75 templates/dcim/consoleserverport.html:18
+#: templates/dcim/frontport.html:116 templates/dcim/inventoryitem_edit.html:44
+msgid "Console Server Port"
+msgstr ""
+
+#: templates/dcim/device.html:52
+msgid "Highlight device"
+msgstr ""
+
+#: templates/dcim/device.html:74
+msgid "Not racked"
+msgstr ""
+
+#: templates/dcim/device.html:81 templates/dcim/site.html:109
+msgid "GPS Coordinates"
+msgstr ""
+
+#: templates/dcim/device.html:87 templates/dcim/site.html:115
+msgid "Map It"
+msgstr ""
+
+#: templates/dcim/device.html:127 templates/dcim/inventoryitem.html:57
+#: templates/dcim/module.html:79 templates/dcim/modulebay.html:73
+#: templates/dcim/rack.html:69
+msgid "Asset Tag"
+msgstr ""
+
+#: templates/dcim/device.html:170
+msgid "View Virtual Chassis"
+msgstr ""
+
+#: templates/dcim/device.html:187
+msgid "Create VDC"
+msgstr ""
+
+#: templates/dcim/device.html:196 templates/dcim/device_edit.html:64
+#: virtualization/forms/model_forms.py:224
+msgid "Management"
+msgstr ""
+
+#: templates/dcim/device.html:217 templates/dcim/device.html:233
+#: templates/virtualization/virtualmachine.html:56
+#: templates/virtualization/virtualmachine.html:72
+msgid "NAT for"
+msgstr ""
+
+#: templates/dcim/device.html:219 templates/dcim/device.html:235
+#: templates/virtualization/virtualmachine.html:58
+#: templates/virtualization/virtualmachine.html:74
+msgid "NAT"
+msgstr ""
+
+#: templates/dcim/device.html:271 templates/dcim/rack.html:77
+msgid "Power Utilization"
+msgstr ""
+
+#: templates/dcim/device.html:276
+msgid "Input"
+msgstr ""
+
+#: templates/dcim/device.html:277
+msgid "Outlets"
+msgstr ""
+
+#: templates/dcim/device.html:278
+msgid "Allocated"
+msgstr ""
+
+#: templates/dcim/device.html:287 templates/dcim/device.html:289
+#: templates/dcim/device.html:305 templates/dcim/powerfeed.html:70
+msgid "VA"
+msgstr ""
+
+#: templates/dcim/device.html:299
+msgctxt "Leg of a power feed"
+msgid "Leg"
+msgstr ""
+
+#: templates/dcim/device.html:329
+#: templates/virtualization/virtualmachine.html:163
+msgid "Add a service"
+msgstr ""
+
+#: templates/dcim/device.html:336 templates/dcim/rack.html:84
+#: templates/dcim/rack_edit.html:38
+msgid "Dimensions"
+msgstr ""
+
+#: templates/dcim/device/base.html:21 templates/dcim/device_list.html:9
+#: templates/dcim/devicetype/base.html:18 templates/dcim/module.html:18
+#: templates/dcim/moduletype/base.html:18
+#: templates/virtualization/virtualmachine_list.html:8
+msgid "Add Components"
+msgstr ""
+
+#: templates/dcim/device/consoleports.html:24
+msgid "Add Console Ports"
+msgstr ""
+
+#: templates/dcim/device/consoleserverports.html:24
+msgid "Add Console Server Ports"
+msgstr ""
+
+#: templates/dcim/device/devicebays.html:10
+msgid "Add Device Bays"
+msgstr ""
+
+#: templates/dcim/device/frontports.html:24
+msgid "Add Front Ports"
+msgstr ""
+
+#: templates/dcim/device/inc/interface_table_controls.html:9
+msgid "Hide Enabled"
+msgstr ""
+
+#: templates/dcim/device/inc/interface_table_controls.html:10
+msgid "Hide Disabled"
+msgstr ""
+
+#: templates/dcim/device/inc/interface_table_controls.html:11
+msgid "Hide Virtual"
+msgstr ""
+
+#: templates/dcim/device/inc/interface_table_controls.html:12
+msgid "Hide Disconnected"
+msgstr ""
+
+#: templates/dcim/device/interfaces.html:28
+#: templates/virtualization/virtualmachine/base.html:21
+msgid "Add Interfaces"
+msgstr ""
+
+#: templates/dcim/device/inventory.html:10
+#: templates/dcim/inc/panels/inventory_items.html:46
+msgid "Add Inventory Item"
+msgstr ""
+
+#: templates/dcim/device/modulebays.html:10
+msgid "Add Module Bays"
+msgstr ""
+
+#: templates/dcim/device/poweroutlets.html:24
+msgid "Add Power Outlets"
+msgstr ""
+
+#: templates/dcim/device/powerports.html:24
+msgid "Add Power Port"
+msgstr ""
+
+#: templates/dcim/device/rearports.html:24
+msgid "Add Rear Ports"
+msgstr ""
+
+#: templates/dcim/device/render_config.html:5
+#: templates/virtualization/virtualmachine/render_config.html:5
+msgid "Config"
+msgstr ""
+
+#: templates/dcim/device/render_config.html:37
+#: templates/virtualization/virtualmachine/render_config.html:37
+msgid "Context Data"
+msgstr ""
+
+#: templates/dcim/device/render_config.html:57
+#: templates/virtualization/virtualmachine/render_config.html:57
+msgid "Download"
+msgstr ""
+
+#: templates/dcim/device/render_config.html:60
+#: templates/virtualization/virtualmachine/render_config.html:60
+msgid "Rendered Config"
+msgstr ""
+
+#: templates/dcim/device/render_config.html:65
+#: templates/virtualization/virtualmachine/render_config.html:65
+msgid "No configuration template found"
+msgstr ""
+
+#: templates/dcim/device_edit.html:44
+msgid "Parent Bay"
+msgstr ""
+
+#: templates/dcim/device_edit.html:48
+#: utilities/templates/form_helpers/render_field.html:20
+msgid "Regenerate Slug"
+msgstr ""
+
+#: templates/dcim/device_edit.html:49 templates/generic/bulk_remove.html:7
+#: utilities/templates/helpers/table_config_form.html:23
+msgid "Remove"
+msgstr ""
+
+#: templates/dcim/device_edit.html:110
+msgid "Local Config Context Data"
+msgstr ""
+
+#: templates/dcim/device_list.html:82
+#: templates/dcim/devicetype/component_templates.html:18
+#: templates/dcim/moduletype/component_templates.html:18
+#: templates/generic/bulk_rename.html:34
+#: templates/virtualization/virtualmachine/interfaces.html:11
+msgid "Rename"
+msgstr ""
+
+#: templates/dcim/devicebay.html:18
+msgid "Device Bay"
+msgstr ""
+
+#: templates/dcim/devicebay.html:48
+msgid "Installed Device"
+msgstr ""
+
+#: templates/dcim/devicebay_delete.html:6
+#, python-format
+msgid "Delete device bay %(devicebay)s?"
+msgstr ""
+
+#: templates/dcim/devicebay_delete.html:11
+#, python-format
+msgid ""
+"Are you sure you want to delete this device bay from %(device)s"
+"strong>?"
+msgstr ""
+
+#: templates/dcim/devicebay_depopulate.html:6
+#, python-format
+msgid "Remove %(device)s from %(device_bay)s?"
+msgstr ""
+
+#: templates/dcim/devicebay_depopulate.html:13
+#, python-format
+msgid ""
+"Are you sure you want to remove %(device)s from "
+"%(device_bay)s?"
+msgstr ""
+
+#: templates/dcim/devicebay_populate.html:13
+msgid "Populate"
+msgstr ""
+
+#: templates/dcim/devicebay_populate.html:22
+msgid "Bay"
+msgstr ""
+
+#: templates/dcim/devicerole.html:14 templates/dcim/platform.html:17
+msgid "Add Device"
+msgstr ""
+
+#: templates/dcim/devicerole.html:43
+msgid "VM Role"
+msgstr ""
+
+#: templates/dcim/devicetype.html:21 templates/dcim/moduletype.html:19
+msgid "Model Name"
+msgstr ""
+
+#: templates/dcim/devicetype.html:28 templates/dcim/moduletype.html:23
+msgid "Part Number"
+msgstr ""
+
+#: templates/dcim/devicetype.html:40
+msgid "Height (U"
+msgstr ""
+
+#: templates/dcim/devicetype.html:44
+msgid "Exclude From Utilization"
+msgstr ""
+
+#: templates/dcim/devicetype.html:62
+msgid "Parent/Child"
+msgstr ""
+
+#: templates/dcim/devicetype.html:74
+msgid "Front Image"
+msgstr ""
+
+#: templates/dcim/devicetype.html:86
+msgid "Rear Image"
+msgstr ""
+
+#: templates/dcim/frontport.html:57
+msgid "Rear Port Position"
+msgstr ""
+
+#: templates/dcim/frontport.html:79 templates/dcim/interface.html:146
+#: templates/dcim/poweroutlet.html:67 templates/dcim/powerport.html:67
+#: templates/dcim/rearport.html:75
+msgid "Marked as Connected"
+msgstr ""
+
+#: templates/dcim/frontport.html:93 templates/dcim/rearport.html:89
+msgid "Connection Status"
+msgstr ""
+
+#: templates/dcim/inc/cable_termination.html:65
+msgid "No termination"
+msgstr ""
+
+#: templates/dcim/inc/cable_toggle_buttons.html:4
+msgid "Mark Planned"
+msgstr ""
+
+#: templates/dcim/inc/cable_toggle_buttons.html:8
+msgid "Mark Installed"
+msgstr ""
+
+#: templates/dcim/inc/connection_endpoints.html:13
+msgid "Path Status"
+msgstr ""
+
+#: templates/dcim/inc/connection_endpoints.html:18
+msgid "Not Reachable"
+msgstr ""
+
+#: templates/dcim/inc/connection_endpoints.html:23
+msgid "Path Endpoints"
+msgstr ""
+
+#: templates/dcim/inc/endpoint_connection.html:8
+#: templates/dcim/powerfeed.html:128 templates/dcim/rearport.html:101
+msgid "Not connected"
+msgstr ""
+
+#: templates/dcim/inc/interface_vlans_table.html:6
+msgid "Untagged"
+msgstr ""
+
+#: templates/dcim/inc/interface_vlans_table.html:37
+msgid "No VLANs Assigned"
+msgstr ""
+
+#: templates/dcim/inc/interface_vlans_table.html:44
+#: templates/ipam/prefix_list.html:16 templates/ipam/prefix_list.html:33
+msgid "Clear"
+msgstr ""
+
+#: templates/dcim/inc/interface_vlans_table.html:47
+msgid "Clear All"
+msgstr ""
+
+#: templates/dcim/interface.html:17
+msgid "Add Child Interface"
+msgstr ""
+
+#: templates/dcim/interface.html:51
+msgid "Speed/Duplex"
+msgstr ""
+
+#: templates/dcim/interface.html:74
+msgid "PoE Mode"
+msgstr ""
+
+#: templates/dcim/interface.html:78
+msgid "PoE Type"
+msgstr ""
+
+#: templates/dcim/interface.html:82
+#: templates/virtualization/vminterface.html:66
+msgid "802.1Q Mode"
+msgstr ""
+
+#: templates/dcim/interface.html:126
+#: templates/virtualization/vminterface.html:62
+msgid "MAC Address"
+msgstr ""
+
+#: templates/dcim/interface.html:153
+msgid "Wireless Link"
+msgstr ""
+
+#: templates/dcim/interface.html:222
+msgid "Peer"
+msgstr ""
+
+#: templates/dcim/interface.html:234
+#: templates/wireless/inc/wirelesslink_interface.html:26
+msgid "Channel"
+msgstr ""
+
+#: templates/dcim/interface.html:243
+#: templates/wireless/inc/wirelesslink_interface.html:32
+msgid "Channel Frequency"
+msgstr ""
+
+#: templates/dcim/interface.html:246 templates/dcim/interface.html:254
+#: templates/dcim/interface.html:265 templates/dcim/interface.html:273
+msgid "MHz"
+msgstr ""
+
+#: templates/dcim/interface.html:262
+#: templates/wireless/inc/wirelesslink_interface.html:42
+msgid "Channel Width"
+msgstr ""
+
+#: templates/dcim/interface.html:291 templates/wireless/wirelesslan.html:15
+#: templates/wireless/wirelesslink.html:24 wireless/forms/bulk_edit.py:59
+#: wireless/forms/bulk_edit.py:101 wireless/forms/filtersets.py:39
+#: wireless/forms/filtersets.py:79 wireless/models.py:81 wireless/models.py:155
+#: wireless/tables/wirelesslan.py:44
+msgid "SSID"
+msgstr ""
+
+#: templates/dcim/interface.html:312
+msgid "LAG Members"
+msgstr ""
+
+#: templates/dcim/interface.html:331
+msgid "No member interfaces"
+msgstr ""
+
+#: templates/dcim/interface.html:355 templates/ipam/fhrpgroup.html:80
+#: templates/ipam/iprange/ip_addresses.html:7
+#: templates/ipam/prefix/ip_addresses.html:7
+#: templates/virtualization/vminterface.html:92
+msgid "Add IP Address"
+msgstr ""
+
+#: templates/dcim/inventoryitem.html:25
+msgid "Parent Item"
+msgstr ""
+
+#: templates/dcim/inventoryitem.html:49
+msgid "Part ID"
+msgstr ""
+
+#: templates/dcim/inventoryitem_bulk_delete.html:5
+msgid "This will also delete all child inventory items of those listed"
+msgstr ""
+
+#: templates/dcim/inventoryitem_edit.html:33
+msgid "Component Assignment"
+msgstr ""
+
+#: templates/dcim/inventoryitem_edit.html:59 templates/dcim/poweroutlet.html:18
+#: templates/dcim/powerport.html:81
+msgid "Power Outlet"
+msgstr ""
+
+#: templates/dcim/location.html:17
+msgid "Add Child Location"
+msgstr ""
+
+#: templates/dcim/location.html:76
+msgid "Child Locations"
+msgstr ""
+
+#: templates/dcim/location.html:84 templates/dcim/site.html:150
+msgid "Add a Location"
+msgstr ""
+
+#: templates/dcim/location.html:98 templates/dcim/site.html:164
+msgid "Add a Device"
+msgstr ""
+
+#: templates/dcim/manufacturer.html:16
+msgid "Add Device Type"
+msgstr ""
+
+#: templates/dcim/manufacturer.html:21
+msgid "Add Module Type"
+msgstr ""
+
+#: templates/dcim/powerfeed.html:56
+msgid "Connected Device"
+msgstr ""
+
+#: templates/dcim/powerfeed.html:66
+msgid "Utilization (Allocated"
+msgstr ""
+
+#: templates/dcim/powerfeed.html:85
+msgid "Electrical Characteristics"
+msgstr ""
+
+#: templates/dcim/powerfeed.html:95
+msgctxt "Abbreviation for volts"
+msgid "V"
+msgstr ""
+
+#: templates/dcim/powerfeed.html:99
+msgctxt "Abbreviation for amperes"
+msgid "A"
+msgstr ""
+
+#: templates/dcim/poweroutlet.html:51
+msgid "Feed Leg"
+msgstr ""
+
+#: templates/dcim/powerpanel.html:77
+msgid "Add Power Feeds"
+msgstr ""
+
+#: templates/dcim/powerport.html:47
+msgid "Maximum Draw"
+msgstr ""
+
+#: templates/dcim/powerport.html:51
+msgid "Allocated Draw"
+msgstr ""
+
+#: templates/dcim/rack.html:73
+msgid "Space Utilization"
+msgstr ""
+
+#: templates/dcim/rack.html:103
+msgid "descending"
+msgstr ""
+
+#: templates/dcim/rack.html:103
+msgid "ascending"
+msgstr ""
+
+#: templates/dcim/rack.html:106
+msgid "Starting Unit"
+msgstr ""
+
+#: templates/dcim/rack.html:132
+msgid "Mounting Depth"
+msgstr ""
+
+#: templates/dcim/rack.html:142
+msgid "Rack Weight"
+msgstr ""
+
+#: templates/dcim/rack.html:152 templates/dcim/rack_edit.html:67
+msgid "Maximum Weight"
+msgstr ""
+
+#: templates/dcim/rack.html:162
+msgid "Total Weight"
+msgstr ""
+
+#: templates/dcim/rack.html:180 templates/dcim/rack_elevation_list.html:16
+msgid "Images and Labels"
+msgstr ""
+
+#: templates/dcim/rack.html:181 templates/dcim/rack_elevation_list.html:17
+msgid "Images only"
+msgstr ""
+
+#: templates/dcim/rack.html:182 templates/dcim/rack_elevation_list.html:18
+msgid "Labels only"
+msgstr ""
+
+#: templates/dcim/rack/reservations.html:9
+msgid "Add reservation"
+msgstr ""
+
+#: templates/dcim/rack_edit.html:21
+msgid "Inventory Control"
+msgstr ""
+
+#: templates/dcim/rack_edit.html:45
+msgid "Outer Dimensions"
+msgstr ""
+
+#: templates/dcim/rack_edit.html:56 templates/dcim/rack_edit.html:71
+msgid "Unit"
+msgstr ""
+
+#: templates/dcim/rack_elevation_list.html:12
+msgid "View List"
+msgstr ""
+
+#: templates/dcim/rack_elevation_list.html:27
+msgid "Sort By"
+msgstr ""
+
+#: templates/dcim/rack_elevation_list.html:77
+msgid "No Racks Found"
+msgstr ""
+
+#: templates/dcim/rack_list.html:8
+msgid "View Elevations"
+msgstr ""
+
+#: templates/dcim/rackreservation.html:47
+msgid "Reservation Details"
+msgstr ""
+
+#: templates/dcim/rackrole.html:10
+msgid "Add Rack"
+msgstr ""
+
+#: templates/dcim/rearport.html:53
+msgid "Positions"
+msgstr ""
+
+#: templates/dcim/region.html:17 templates/dcim/sitegroup.html:17
+msgid "Add Site"
+msgstr ""
+
+#: templates/dcim/region.html:56
+msgid "Child Regions"
+msgstr ""
+
+#: templates/dcim/region.html:64
+msgid "Add Region"
+msgstr ""
+
+#: templates/dcim/site.html:69
+msgid "Facility"
+msgstr ""
+
+#: templates/dcim/site.html:77
+msgid "Time Zone"
+msgstr ""
+
+#: templates/dcim/site.html:80
+msgid "UTC"
+msgstr ""
+
+#: templates/dcim/site.html:81
+msgid "Site time"
+msgstr ""
+
+#: templates/dcim/site.html:88
+msgid "Physical Address"
+msgstr ""
+
+#: templates/dcim/site.html:94
+msgid "Map"
+msgstr ""
+
+#: templates/dcim/site.html:105
+msgid "Shipping Address"
+msgstr ""
+
+#: templates/dcim/sitegroup.html:56 templates/tenancy/contactgroup.html:49
+#: templates/tenancy/tenantgroup.html:58
+#: templates/wireless/wirelesslangroup.html:56
+msgid "Child Groups"
+msgstr ""
+
+#: templates/dcim/sitegroup.html:64
+msgid "Add Site Group"
+msgstr ""
+
+#: templates/dcim/trace/attachment.html:5
+#: templates/extras/exporttemplate.html:37
+msgid "Attachment"
+msgstr ""
+
+#: templates/dcim/virtualchassis.html:86
+msgid "Add Member"
+msgstr ""
+
+#: templates/dcim/virtualchassis_add.html:18
+msgid "Member Devices"
+msgstr ""
+
+#: templates/dcim/virtualchassis_add_member.html:6
+#, python-format
+msgid "Add New Member to Virtual Chassis %(virtual_chassis)s"
+msgstr ""
+
+#: templates/dcim/virtualchassis_add_member.html:17
+msgid "Add New Member"
+msgstr ""
+
+#: templates/dcim/virtualchassis_add_member.html:25
+msgid "Add Another"
+msgstr ""
+
+#: templates/dcim/virtualchassis_edit.html:7
+#, python-format
+msgid "Editing Virtual Chassis %(name)s"
+msgstr ""
+
+#: templates/dcim/virtualchassis_edit.html:54
+msgid "Rack/Unit"
+msgstr ""
+
+#: templates/dcim/virtualchassis_remove_member.html:5
+msgid "Remove Virtual Chassis Member"
+msgstr ""
+
+#: templates/dcim/virtualchassis_remove_member.html:9
+#, python-format
+msgid ""
+"Are you sure you want to remove %(device)s from virtual "
+"chassis %(name)s?"
+msgstr ""
+
+#: templates/dcim/virtualdevicecontext.html:29 templates/ipam/l2vpn.html:19
+msgid "Identifier"
+msgstr ""
+
+#: templates/exceptions/import_error.html:6
+msgid ""
+"A module import error occurred during this request. Common causes include "
+"the following:"
+msgstr ""
+
+#: templates/exceptions/import_error.html:10
+msgid "Missing required packages"
+msgstr ""
+
+#: templates/exceptions/import_error.html:11
+msgid ""
+"This installation of NetBox might be missing one or more required Python "
+"packages. These packages are listed in requirements.txt
and "
+"local_requirements.txt
, and are normally installed as part of "
+"the installation or upgrade process. To verify installed packages, run "
+"pip freeze
from the console and compare the output to the list "
+"of required packages."
+msgstr ""
+
+#: templates/exceptions/import_error.html:20
+msgid "WSGI service not restarted after upgrade"
+msgstr ""
+
+#: templates/exceptions/import_error.html:21
+msgid ""
+"If this installation has recently been upgraded, check that the WSGI service "
+"(e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code "
+"is running."
+msgstr ""
+
+#: templates/exceptions/permission_error.html:6
+msgid ""
+"A file permission error was detected while processing this request. Common "
+"causes include the following:"
+msgstr ""
+
+#: templates/exceptions/permission_error.html:10
+msgid "Insufficient write permission to the media root"
+msgstr ""
+
+#: templates/exceptions/permission_error.html:11
+#, python-format
+msgid ""
+"The configured media root is %(media_root)s
. Ensure that the "
+"user NetBox runs as has access to write files to all locations within this "
+"path."
+msgstr ""
+
+#: templates/exceptions/programming_error.html:6
+msgid ""
+"A database programming error was detected while processing this request. "
+"Common causes include the following:"
+msgstr ""
+
+#: templates/exceptions/programming_error.html:10
+msgid "Database migrations missing"
+msgstr ""
+
+#: templates/exceptions/programming_error.html:11
+msgid ""
+"When upgrading to a new NetBox release, the upgrade script must be run to "
+"apply any new database migrations. You can run migrations manually by "
+"executing python3 manage.py migrate
from the command line."
+msgstr ""
+
+#: templates/exceptions/programming_error.html:18
+msgid "Unsupported PostgreSQL version"
+msgstr ""
+
+#: templates/exceptions/programming_error.html:19
+msgid ""
+"Ensure that PostgreSQL version 12 or later is in use. You can check this by "
+"connecting to the database using NetBox's credentials and issuing a query "
+"for SELECT VERSION()
."
+msgstr ""
+
+#: templates/extras/admin/plugins_list.html:4
+#: templates/extras/admin/plugins_list.html:9
+#: templates/extras/admin/plugins_list.html:13
+msgid "Installed Plugins"
+msgstr ""
+
+#: templates/extras/admin/plugins_list.html:23
+msgid "Package Name"
+msgstr ""
+
+#: templates/extras/admin/plugins_list.html:24
+msgid "Author"
+msgstr ""
+
+#: templates/extras/admin/plugins_list.html:25
+msgid "Author Email"
+msgstr ""
+
+#: templates/extras/admin/plugins_list.html:27
+msgid "Version"
+msgstr ""
+
+#: templates/extras/configcontext.html:46
+#: templates/extras/configtemplate.html:38
+#: templates/extras/exporttemplate.html:57
+msgid "The data file associated with this object has been deleted"
+msgstr ""
+
+#: templates/extras/configcontext.html:55
+#: templates/extras/configtemplate.html:47
+#: templates/extras/exporttemplate.html:66
+msgid "Data Synced"
+msgstr ""
+
+#: templates/extras/configcontext_list.html:7
+#: templates/extras/configtemplate_list.html:7
+#: templates/extras/exporttemplate_list.html:7
+msgid "Sync Data"
+msgstr ""
+
+#: templates/extras/configrevision.html:47
+msgid "Default unit height"
+msgstr ""
+
+#: templates/extras/configrevision.html:51
+msgid "Default unit width"
+msgstr ""
+
+#: templates/extras/configrevision.html:63
+msgid "Default voltage"
+msgstr ""
+
+#: templates/extras/configrevision.html:67
+msgid "Default amperage"
+msgstr ""
+
+#: templates/extras/configrevision.html:71
+msgid "Default max utilization"
+msgstr ""
+
+#: templates/extras/configrevision.html:83
+msgid "Enforce global unique"
+msgstr ""
+
+#: templates/extras/configrevision.html:135
+msgid "Paginate count"
+msgstr ""
+
+#: templates/extras/configrevision.html:139
+msgid "Max page size"
+msgstr ""
+
+#: templates/extras/configrevision.html:163
+msgid "Default user preferences"
+msgstr ""
+
+#: templates/extras/configrevision.html:187
+msgid "Job retention"
+msgstr ""
+
+#: templates/extras/configrevision.html:199
+msgid "Comment"
+msgstr ""
+
+#: templates/extras/configrevision_restore.html:8
+#: templates/extras/configrevision_restore.html:43
+#: templates/extras/configrevision_restore.html:79
+msgid "Restore"
+msgstr ""
+
+#: templates/extras/configrevision_restore.html:21
+msgid "Config revisions"
+msgstr ""
+
+#: templates/extras/configrevision_restore.html:54
+msgid "Parameter"
+msgstr ""
+
+#: templates/extras/configrevision_restore.html:55
+msgid "Current Value"
+msgstr ""
+
+#: templates/extras/configrevision_restore.html:56
+msgid "New Value"
+msgstr ""
+
+#: templates/extras/configrevision_restore.html:66
+msgid "Changed"
+msgstr ""
+
+#: templates/extras/configtemplate.html:58
+msgid "Environment Parameters"
+msgstr ""
+
+#: templates/extras/configtemplate.html:69
+#: templates/extras/exporttemplate.html:88
+msgid "Template"
+msgstr ""
+
+#: templates/extras/customfield.html:31 templates/extras/customlink.html:22
+msgid "Group Name"
+msgstr ""
+
+#: templates/extras/customfield.html:43
+msgid "Cloneable"
+msgstr ""
+
+#: templates/extras/customfield.html:53
+msgid "Default Value"
+msgstr ""
+
+#: templates/extras/customfield.html:64
+msgid "Search Weight"
+msgstr ""
+
+#: templates/extras/customfield.html:74
+msgid "Filter Logic"
+msgstr ""
+
+#: templates/extras/customfield.html:78
+msgid "Display Weight"
+msgstr ""
+
+#: templates/extras/customfield.html:104
+msgid "Validation Rules"
+msgstr ""
+
+#: templates/extras/customfield.html:108
+msgid "Minimum Value"
+msgstr ""
+
+#: templates/extras/customfield.html:112
+msgid "Maximum Value"
+msgstr ""
+
+#: templates/extras/customfield.html:116
+msgid "Regular Expression"
+msgstr ""
+
+#: templates/extras/customlink.html:30
+msgid "Button Class"
+msgstr ""
+
+#: templates/extras/customlink.html:41 templates/extras/exporttemplate.html:73
+#: templates/extras/savedfilter.html:41 templates/extras/webhook.html:102
+msgid "Assigned Models"
+msgstr ""
+
+#: templates/extras/customlink.html:57
+msgid "Link Text"
+msgstr ""
+
+#: templates/extras/customlink.html:65
+msgid "Link URL"
+msgstr ""
+
+#: templates/extras/dashboard/reset.html:4 templates/home.html:63
+msgid "Reset Dashboard"
+msgstr ""
+
+#: templates/extras/dashboard/reset.html:8
+msgid ""
+"This will remove all configured widgets and restore the "
+"default dashboard configuration."
+msgstr ""
+
+#: templates/extras/dashboard/reset.html:13
+msgid ""
+"This change affects only your dashboard, and will not impact other "
+"users."
+msgstr ""
+
+#: templates/extras/dashboard/widget_add.html:7
+msgid "Add a Widget"
+msgstr ""
+
+#: templates/extras/dashboard/widgets/bookmarks.html:14
+msgid "No bookmarks have been added yet."
+msgstr ""
+
+#: templates/extras/dashboard/widgets/objectcounts.html:15
+msgid "No permission"
+msgstr ""
+
+#: templates/extras/dashboard/widgets/objectlist.html:6
+msgid "No permission to view this content"
+msgstr ""
+
+#: templates/extras/dashboard/widgets/objectlist.html:10
+msgid "Unable to load content. Invalid view name"
+msgstr ""
+
+#: templates/extras/dashboard/widgets/rssfeed.html:12
+msgid "No content found"
+msgstr ""
+
+#: templates/extras/dashboard/widgets/rssfeed.html:18
+msgid "There was a problem fetching the RSS feed"
+msgstr ""
+
+#: templates/extras/dashboard/widgets/rssfeed.html:21
+msgid "HTTP"
+msgstr ""
+
+#: templates/extras/exporttemplate.html:29
+msgid "MIME Type"
+msgstr ""
+
+#: templates/extras/exporttemplate.html:33
+msgid "File Extension"
+msgstr ""
+
+#: templates/extras/htmx/report_result.html:9
+#: templates/extras/htmx/script_result.html:10
+msgid "Scheduled for"
+msgstr ""
+
+#: templates/extras/htmx/report_result.html:14
+#: templates/extras/htmx/script_result.html:15
+msgid "Duration"
+msgstr ""
+
+#: templates/extras/htmx/report_result.html:20
+msgid "Report Methods"
+msgstr ""
+
+#: templates/extras/htmx/report_result.html:38
+msgid "Report Results"
+msgstr ""
+
+#: templates/extras/htmx/report_result.html:44
+#: templates/extras/htmx/script_result.html:26
+msgid "Level"
+msgstr ""
+
+#: templates/extras/htmx/report_result.html:46
+#: templates/extras/htmx/script_result.html:27
+msgid "Message"
+msgstr ""
+
+#: templates/extras/htmx/script_result.html:21
+msgid "Script Log"
+msgstr ""
+
+#: templates/extras/htmx/script_result.html:25
+msgid "Line"
+msgstr ""
+
+#: templates/extras/htmx/script_result.html:38
+msgid "No log output"
+msgstr ""
+
+#: templates/extras/htmx/script_result.html:46
+msgid "Exec Time"
+msgstr ""
+
+#: templates/extras/htmx/script_result.html:46
+msgctxt "Unit of time"
+msgid "seconds"
+msgstr ""
+
+#: templates/extras/htmx/script_result.html:50
+msgid "Output"
+msgstr ""
+
+#: templates/extras/inc/result_pending.html:4
+msgid "Loading"
+msgstr ""
+
+#: templates/extras/inc/result_pending.html:6
+msgid "Results pending"
+msgstr ""
+
+#: templates/extras/journalentry.html:16
+msgid "Journal Entry"
+msgstr ""
+
+#: templates/extras/object_changelog.html:15
+#: templates/extras/objectchange_list.html:9
+msgid "Change log retention"
+msgstr ""
+
+#: templates/extras/object_changelog.html:15
+#: templates/extras/objectchange_list.html:9
+msgid "days"
+msgstr ""
+
+#: templates/extras/object_changelog.html:15
+#: templates/extras/objectchange_list.html:9
+msgid "Indefinite"
+msgstr ""
+
+#: templates/extras/object_configcontext.html:11
+msgid "Rendered Context"
+msgstr ""
+
+#: templates/extras/object_configcontext.html:22
+msgid "Local Context"
+msgstr ""
+
+#: templates/extras/object_configcontext.html:34
+msgid "The local config context overwrites all source contexts"
+msgstr ""
+
+#: templates/extras/object_configcontext.html:40
+msgid "Source Contexts"
+msgstr ""
+
+#: templates/extras/object_journal.html:18
+msgid "New Journal Entry"
+msgstr ""
+
+#: templates/extras/objectchange.html:29
+#: templates/users/objectpermission.html:45
+msgid "Change"
+msgstr ""
+
+#: templates/extras/objectchange.html:84
+msgid "Difference"
+msgstr ""
+
+#: templates/extras/objectchange.html:87
+msgid "Previous"
+msgstr ""
+
+#: templates/extras/objectchange.html:90
+msgid "Next"
+msgstr ""
+
+#: templates/extras/objectchange.html:98
+msgid "Object Created"
+msgstr ""
+
+#: templates/extras/objectchange.html:100
+msgid "Object Deleted"
+msgstr ""
+
+#: templates/extras/objectchange.html:102
+msgid "No Changes"
+msgstr ""
+
+#: templates/extras/objectchange.html:117
+msgid "Pre-Change Data"
+msgstr ""
+
+#: templates/extras/objectchange.html:126
+msgid "Warning: Comparing non-atomic change to previous change record"
+msgstr ""
+
+#: templates/extras/objectchange.html:136
+msgid "Post-Change Data"
+msgstr ""
+
+#: templates/extras/objectchange.html:157
+#, python-format
+msgid "See All %(count)s Changes"
+msgstr ""
+
+#: templates/extras/report.html:14
+msgid "This report is invalid and cannot be run."
+msgstr ""
+
+#: templates/extras/report.html:23 templates/extras/report_list.html:88
+msgid "Run Again"
+msgstr ""
+
+#: templates/extras/report.html:25 templates/extras/report_list.html:90
+msgid "Run Report"
+msgstr ""
+
+#: templates/extras/report.html:36
+msgid "Last run"
+msgstr ""
+
+#: templates/extras/report/base.html:30
+msgid "Report"
+msgstr ""
+
+#: templates/extras/report_list.html:48 templates/extras/script_list.html:54
+msgid "Last Run"
+msgstr ""
+
+#: templates/extras/report_list.html:70 templates/extras/script_list.html:77
+msgid "Never"
+msgstr ""
+
+#: templates/extras/report_list.html:75
+msgid "Report has no test methods"
+msgstr ""
+
+#: templates/extras/report_list.html:76
+msgid "Invalid"
+msgstr ""
+
+#: templates/extras/report_list.html:125
+msgid "No Reports Found"
+msgstr ""
+
+#: templates/extras/report_list.html:128
+#, python-format
+msgid ""
+"Get started by creating a report from "
+"an uploaded file or data source."
+msgstr ""
+
+#: templates/extras/script.html:13
+msgid "You do not have permission to run scripts"
+msgstr ""
+
+#: templates/extras/script.html:37
+msgid "Run Script"
+msgstr ""
+
+#: templates/extras/script/base.html:29
+msgid "Script"
+msgstr ""
+
+#: templates/extras/script_list.html:44
+#, python-format
+msgid ""
+"Script file at %(file_path)s
could not be loaded."
+msgstr ""
+
+#: templates/extras/script_list.html:91
+msgid "No Scripts Found"
+msgstr ""
+
+#: templates/extras/script_list.html:94
+#, python-format
+msgid ""
+"Get started by creating a script from "
+"an uploaded file or data source."
+msgstr ""
+
+#: templates/extras/script_result.html:42
+msgid "Log"
+msgstr ""
+
+#: templates/extras/tag.html:35
+msgid "Tagged Items"
+msgstr ""
+
+#: templates/extras/tag.html:47
+msgid "Allowed Object Types"
+msgstr ""
+
+#: templates/extras/tag.html:56
+msgid "Any"
+msgstr ""
+
+#: templates/extras/tag.html:63
+msgid "Tagged Item Types"
+msgstr ""
+
+#: templates/extras/tag.html:89
+msgid "Tagged Objects"
+msgstr ""
+
+#: templates/extras/webhook.html:45
+msgid "Job start"
+msgstr ""
+
+#: templates/extras/webhook.html:49
+msgid "Job end"
+msgstr ""
+
+#: templates/extras/webhook.html:62
+msgid "HTTP Method"
+msgstr ""
+
+#: templates/extras/webhook.html:70
+msgid "HTTP Content Type"
+msgstr ""
+
+#: templates/extras/webhook.html:87
+msgid "SSL Verification"
+msgstr ""
+
+#: templates/extras/webhook.html:128
+msgid "Additional Headers"
+msgstr ""
+
+#: templates/extras/webhook.html:140
+msgid "Body Template"
+msgstr ""
+
+#: templates/generic/bulk_add_component.html:15
+msgid "Bulk Creation"
+msgstr ""
+
+#: templates/generic/bulk_add_component.html:20
+#: templates/generic/bulk_edit.html:28
+msgid "Selected Objects"
+msgstr ""
+
+#: templates/generic/bulk_add_component.html:46
+msgid "to Add"
+msgstr ""
+
+#: templates/generic/bulk_delete.html:24
+msgid "Confirm Bulk Deletion"
+msgstr ""
+
+#: templates/generic/bulk_delete.html:26
+msgctxt "Noun"
+msgid "Warning"
+msgstr ""
+
+#: templates/generic/bulk_delete.html:27
+#, python-format
+msgid ""
+"The following operation will delete %(count)s "
+"%(type_plural)s. Please carefully review the objects to be deleted and "
+"confirm below."
+msgstr ""
+
+#: templates/generic/bulk_edit.html:16 templates/generic/object_edit.html:17
+msgid "Editing"
+msgstr ""
+
+#: templates/generic/bulk_edit.html:23
+msgid "Bulk Edit"
+msgstr ""
+
+#: templates/generic/bulk_edit.html:124 templates/generic/bulk_rename.html:42
+msgid "Apply"
+msgstr ""
+
+#: templates/generic/bulk_import.html:14
+msgid "Bulk Import"
+msgstr ""
+
+#: templates/generic/bulk_import.html:20
+msgid "Direct Import"
+msgstr ""
+
+#: templates/generic/bulk_import.html:25
+msgid "Upload File"
+msgstr ""
+
+#: templates/generic/bulk_import.html:51 templates/generic/bulk_import.html:73
+#: templates/generic/bulk_import.html:95
+msgid "Submit"
+msgstr ""
+
+#: templates/generic/bulk_import.html:110
+msgid "Field Options"
+msgstr ""
+
+#: templates/generic/bulk_import.html:117
+msgid "Accessor"
+msgstr ""
+
+#: templates/generic/bulk_import.html:154
+msgid "Import Value"
+msgstr ""
+
+#: templates/generic/bulk_import.html:181
+msgid "Format: YYYY-MM-DD"
+msgstr ""
+
+#: templates/generic/bulk_import.html:183
+msgid "Specify true or false"
+msgstr ""
+
+#: templates/generic/bulk_import.html:195
+msgid "Required fields must be specified for all objects."
+msgstr ""
+
+#: templates/generic/bulk_import.html:201
+#, python-format
+msgid ""
+"Related objects may be referenced by any unique attribute. For example, "
+"%(example)s
would identify a VRF by its route distinguisher."
+msgstr ""
+
+#: templates/generic/bulk_remove.html:13
+msgid "Confirm Bulk Removal"
+msgstr ""
+
+#: templates/generic/bulk_remove.html:15
+#, python-format
+msgid ""
+"Warning: The following operation will remove %(count)s "
+"%(obj_type_plural)s from %(parent_obj)s."
+msgstr ""
+
+#: templates/generic/bulk_remove.html:21
+#, python-format
+msgid ""
+"Please carefully review the %(obj_type_plural)s to be removed and confirm "
+"below."
+msgstr ""
+
+#: templates/generic/bulk_remove.html:38
+#, python-format
+msgid "Delete these %(count)s %(obj_type_plural)s"
+msgstr ""
+
+#: templates/generic/bulk_rename.html:7
+msgid "Renaming"
+msgstr ""
+
+#: templates/generic/bulk_rename.html:16
+msgid "Current Name"
+msgstr ""
+
+#: templates/generic/bulk_rename.html:17
+msgid "New Name"
+msgstr ""
+
+#: templates/generic/bulk_rename.html:40
+#: utilities/templates/widgets/markdown_input.html:11
+msgid "Preview"
+msgstr ""
+
+#: templates/generic/confirmation_form.html:16
+msgid "Are you sure"
+msgstr ""
+
+#: templates/generic/confirmation_form.html:19
+msgid "Confirm"
+msgstr ""
+
+#: templates/generic/object.html:51
+msgid "ago"
+msgstr ""
+
+#: templates/generic/object_children.html:27
+#: utilities/templates/buttons/bulk_edit.html:4
+msgid "Edit Selected"
+msgstr ""
+
+#: templates/generic/object_children.html:41
+#: utilities/templates/buttons/bulk_delete.html:4
+msgid "Delete Selected"
+msgstr ""
+
+#: templates/generic/object_edit.html:19
+#, python-format
+msgid "Add a new %(object_type)s"
+msgstr ""
+
+#: templates/generic/object_edit.html:47
+msgid "View model documentation"
+msgstr ""
+
+#: templates/generic/object_edit.html:48
+msgid "Help"
+msgstr ""
+
+#: templates/generic/object_edit.html:73
+msgid "Create & Add Another"
+msgstr ""
+
+#: templates/generic/object_list.html:48 templates/search.html:13
+msgid "Results"
+msgstr ""
+
+#: templates/generic/object_list.html:54
+msgid "Filters"
+msgstr ""
+
+#: templates/generic/object_list.html:94
+#, python-format
+msgid ""
+"Select all %(count)s %(object_type_plural)s matching query"
+msgstr ""
+
+#: templates/home.html:12
+msgid "New Release Available"
+msgstr ""
+
+#: templates/home.html:14
+msgid "is available"
+msgstr ""
+
+#: templates/home.html:17
+msgctxt "Document title"
+msgid "Upgrade Instructions"
+msgstr ""
+
+#: templates/home.html:37
+msgid "Unlock Dashboard"
+msgstr ""
+
+#: templates/home.html:46
+msgid "Lock Dashboard"
+msgstr ""
+
+#: templates/home.html:57
+msgid "Add Widget"
+msgstr ""
+
+#: templates/home.html:60
+msgid "Save Layout"
+msgstr ""
+
+#: templates/htmx/delete_form.html:7
+msgid "Confirm Deletion"
+msgstr ""
+
+#: templates/htmx/delete_form.html:11
+#, python-format
+msgid ""
+"Are you sure you want to delete "
+"%(object_type)s %(object)s?"
+msgstr ""
+
+#: templates/htmx/object_selector.html:5
+msgid "Select"
+msgstr ""
+
+#: templates/inc/filter_list.html:50
+#: utilities/templates/helpers/table_config_form.html:39
+msgid "Reset"
+msgstr ""
+
+#: templates/inc/missing_prerequisites.html:7
+#, python-format
+msgid ""
+"Before you can add a %(model)s you must first create a "
+"%(prerequisite_model)s."
+msgstr ""
+
+#: templates/inc/paginator.html:38 templates/inc/paginator_htmx.html:53
+msgid "Per Page"
+msgstr ""
+
+#: templates/inc/paginator.html:49 templates/inc/paginator_htmx.html:69
+#, python-format
+msgid "Showing %(start)s-%(end)s of %(total)s"
+msgstr ""
+
+#: templates/inc/panels/image_attachments.html:10
+msgid "Attach an image"
+msgstr ""
+
+#: templates/inc/panels/related_objects.html:5
+msgid "Related Objects"
+msgstr ""
+
+#: templates/inc/panels/tags.html:11
+msgid "No tags assigned"
+msgstr ""
+
+#: templates/inc/profile_button.html:12 templates/inc/profile_button.html:62
+msgid "Dark Mode"
+msgstr ""
+
+#: templates/inc/profile_button.html:45
+msgid "Log Out"
+msgstr ""
+
+#: templates/inc/profile_button.html:53
+msgid "Log In"
+msgstr ""
+
+#: templates/inc/sync_warning.html:7
+msgid "Data is out of sync with upstream file"
+msgstr ""
+
+#: templates/inc/table_controls_htmx.html:16
+#: templates/inc/table_controls_htmx.html:18
+msgid "Configure Table"
+msgstr ""
+
+#: templates/ipam/aggregate.html:15 templates/ipam/ipaddress.html:17
+#: templates/ipam/iprange.html:16 templates/ipam/prefix.html:15
+msgid "Family"
+msgstr ""
+
+#: templates/ipam/aggregate.html:40
+msgid "Date Added"
+msgstr ""
+
+#: templates/ipam/aggregate/prefixes.html:8
+#: templates/ipam/prefix/prefixes.html:8 templates/ipam/role.html:10
+msgid "Add Prefix"
+msgstr ""
+
+#: templates/ipam/asn.html:24
+msgid "AS Number"
+msgstr ""
+
+#: templates/ipam/fhrpgroup.html:55
+msgid "Authentication Type"
+msgstr ""
+
+#: templates/ipam/fhrpgroup.html:59
+msgid "Authentication Key"
+msgstr ""
+
+#: templates/ipam/fhrpgroup.html:72
+msgid "Virtual IP Addresses"
+msgstr ""
+
+#: templates/ipam/fhrpgroupassignment_edit.html:8
+msgid "FHRP Group Assignment"
+msgstr ""
+
+#: templates/ipam/inc/ipaddress_edit_header.html:19
+msgid "Assign IP"
+msgstr ""
+
+#: templates/ipam/inc/ipaddress_edit_header.html:28
+msgid "Bulk Create"
+msgstr ""
+
+#: templates/ipam/inc/panels/fhrp_groups.html:12
+msgid "Virtual IPs"
+msgstr ""
+
+#: templates/ipam/inc/panels/fhrp_groups.html:52
+msgid "Create Group"
+msgstr ""
+
+#: templates/ipam/inc/panels/fhrp_groups.html:57
+msgid "Assign Group"
+msgstr ""
+
+#: templates/ipam/inc/toggle_available.html:7
+msgid "Show Assigned"
+msgstr ""
+
+#: templates/ipam/inc/toggle_available.html:10
+msgid "Show Available"
+msgstr ""
+
+#: templates/ipam/inc/toggle_available.html:13
+msgid "Show All"
+msgstr ""
+
+#: templates/ipam/ipaddress.html:26 templates/ipam/iprange.html:48
+#: templates/ipam/prefix.html:24
+msgid "Global"
+msgstr ""
+
+#: templates/ipam/ipaddress.html:88
+msgid "NAT (outside)"
+msgstr ""
+
+#: templates/ipam/ipaddress_assign.html:8
+msgid "Assign an IP Address"
+msgstr ""
+
+#: templates/ipam/ipaddress_assign.html:23
+msgid "Select IP Address"
+msgstr ""
+
+#: templates/ipam/ipaddress_assign.html:39
+msgid "Search Results"
+msgstr ""
+
+#: templates/ipam/ipaddress_bulk_add.html:6
+msgid "Bulk Add IP Addresses"
+msgstr ""
+
+#: templates/ipam/ipaddress_edit.html:35
+msgid "Interface Assignment"
+msgstr ""
+
+#: templates/ipam/ipaddress_edit.html:74
+msgid "NAT IP (Inside"
+msgstr ""
+
+#: templates/ipam/iprange.html:20
+msgid "Starting Address"
+msgstr ""
+
+#: templates/ipam/iprange.html:24
+msgid "Ending Address"
+msgstr ""
+
+#: templates/ipam/iprange.html:36 templates/ipam/prefix.html:104
+msgid "Marked fully utilized"
+msgstr ""
+
+#: templates/ipam/l2vpn.html:11 templates/ipam/l2vpntermination.html:10
+msgid "L2VPN Attributes"
+msgstr ""
+
+#: templates/ipam/l2vpn.html:65
+msgid "Add a Termination"
+msgstr ""
+
+#: templates/ipam/l2vpntermination_edit.html:9
+msgid "L2VPN Termination"
+msgstr ""
+
+#: templates/ipam/prefix.html:112
+msgid "Child IPs"
+msgstr ""
+
+#: templates/ipam/prefix.html:120
+msgid "Available IPs"
+msgstr ""
+
+#: templates/ipam/prefix.html:132
+msgid "First available IP"
+msgstr ""
+
+#: templates/ipam/prefix.html:151
+msgid "Addressing Details"
+msgstr ""
+
+#: templates/ipam/prefix.html:181
+msgid "Prefix Details"
+msgstr ""
+
+#: templates/ipam/prefix.html:187
+msgid "Network Address"
+msgstr ""
+
+#: templates/ipam/prefix.html:191
+msgid "Network Mask"
+msgstr ""
+
+#: templates/ipam/prefix.html:195
+msgid "Wildcard Mask"
+msgstr ""
+
+#: templates/ipam/prefix.html:199
+msgid "Broadcast Address"
+msgstr ""
+
+#: templates/ipam/prefix/ip_ranges.html:7
+msgid "Add IP Range"
+msgstr ""
+
+#: templates/ipam/prefix_list.html:7
+msgid "Hide Depth Indicators"
+msgstr ""
+
+#: templates/ipam/prefix_list.html:11
+msgid "Max Depth"
+msgstr ""
+
+#: templates/ipam/prefix_list.html:28
+msgid "Max Length"
+msgstr ""
+
+#: templates/ipam/rir.html:10
+msgid "Add Aggregate"
+msgstr ""
+
+#: templates/ipam/routetarget.html:10
+msgid "Route Target"
+msgstr ""
+
+#: templates/ipam/routetarget.html:40
+msgid "Importing VRFs"
+msgstr ""
+
+#: templates/ipam/routetarget.html:49
+msgid "Exporting VRFs"
+msgstr ""
+
+#: templates/ipam/routetarget.html:60
+msgid "Importing L2VPNs"
+msgstr ""
+
+#: templates/ipam/routetarget.html:69
+msgid "Exporting L2VPNs"
+msgstr ""
+
+#: templates/ipam/service.html:22 templates/ipam/service_create.html:8
+#: templates/ipam/service_edit.html:8
+msgid "Service"
+msgstr ""
+
+#: templates/ipam/service_create.html:43
+msgid "From Template"
+msgstr ""
+
+#: templates/ipam/service_create.html:48
+msgid "Custom"
+msgstr ""
+
+#: templates/ipam/service_edit.html:37
+msgid "Port(s)"
+msgstr ""
+
+#: templates/ipam/vlan.html:95
+msgid "Add a Prefix"
+msgstr ""
+
+#: templates/ipam/vlangroup.html:18
+msgid "Add VLAN"
+msgstr ""
+
+#: templates/ipam/vlangroup.html:43
+msgid "Permitted VIDs"
+msgstr ""
+
+#: templates/ipam/vrf.html:19
+msgid "Route Distinguisher"
+msgstr ""
+
+#: templates/ipam/vrf.html:32
+msgid "Unique IP Space"
+msgstr ""
+
+#: templates/login.html:20
+#: utilities/templates/form_helpers/render_errors.html:7
+msgid "Errors"
+msgstr ""
+
+#: templates/login.html:48
+msgid "Sign In"
+msgstr ""
+
+#: templates/login.html:54
+msgid "Or use a single sign-on (SSO) provider"
+msgstr ""
+
+#: templates/login.html:68
+msgid "Toggle Color Mode"
+msgstr ""
+
+#: templates/media_failure.html:7
+msgid "Static Media Failure - NetBox"
+msgstr ""
+
+#: templates/media_failure.html:21
+msgid "Static Media Failure"
+msgstr ""
+
+#: templates/media_failure.html:23
+msgid "The following static media file failed to load"
+msgstr ""
+
+#: templates/media_failure.html:26
+msgid "Check the following"
+msgstr ""
+
+#: templates/media_failure.html:29
+msgid ""
+"manage.py collectstatic
was run during the most recent upgrade. "
+"This installs the most recent iteration of each static file into the static "
+"root path."
+msgstr ""
+
+#: templates/media_failure.html:35
+#, python-format
+msgid ""
+"The HTTP service (e.g. nginx or Apache) is configured to serve files from "
+"the STATIC_ROOT
path. Refer to the "
+"installation documentation for further guidance."
+msgstr ""
+
+#: templates/media_failure.html:47
+#, python-format
+msgid ""
+"The file %(filename)s
exists in the static root directory and "
+"is readable by the HTTP server."
+msgstr ""
+
+#: templates/media_failure.html:55
+#, python-format
+msgid ""
+"Click here to attempt loading NetBox again."
+msgstr ""
+
+#: templates/tenancy/contact.html:18 tenancy/filtersets.py:123
+#: tenancy/forms/bulk_edit.py:136 tenancy/forms/filtersets.py:103
+#: tenancy/forms/forms.py:56 tenancy/forms/model_forms.py:112
+#: tenancy/forms/model_forms.py:135 tenancy/tables/contacts.py:98
+msgid "Contact"
+msgstr ""
+
+#: templates/tenancy/contact.html:30 tenancy/forms/bulk_edit.py:98
+msgid "Title"
+msgstr ""
+
+#: templates/tenancy/contact.html:34 tenancy/forms/bulk_edit.py:103
+#: tenancy/tables/contacts.py:64
+msgid "Phone"
+msgstr ""
+
+#: templates/tenancy/contact.html:86 tenancy/tables/contacts.py:73
+msgid "Assignments"
+msgstr ""
+
+#: templates/tenancy/contactassignment_edit.html:12
+msgid "Contact Assignment"
+msgstr ""
+
+#: templates/tenancy/contactgroup.html:19 tenancy/forms/forms.py:66
+#: tenancy/forms/model_forms.py:79
+msgid "Contact Group"
+msgstr ""
+
+#: templates/tenancy/contactgroup.html:57
+msgid "Add Contact Group"
+msgstr ""
+
+#: templates/tenancy/contactrole.html:15 tenancy/filtersets.py:128
+#: tenancy/forms/forms.py:61 tenancy/forms/model_forms.py:93
+msgid "Contact Role"
+msgstr ""
+
+#: templates/tenancy/object_contacts.html:9
+msgid "Add a contact"
+msgstr ""
+
+#: templates/tenancy/tenantgroup.html:17
+msgid "Add Tenant"
+msgstr ""
+
+#: templates/tenancy/tenantgroup.html:27 tenancy/forms/model_forms.py:34
+#: tenancy/tables/columns.py:51 tenancy/tables/columns.py:61
+msgid "Tenant Group"
+msgstr ""
+
+#: templates/tenancy/tenantgroup.html:66
+msgid "Add Tenant Group"
+msgstr ""
+
+#: templates/users/group.html:37 templates/users/user.html:61
+msgid "Assigned Permissions"
+msgstr ""
+
+#: templates/users/objectpermission.html:6
+#: templates/users/objectpermission.html:14 users/forms/filtersets.py:69
+msgid "Permission"
+msgstr ""
+
+#: templates/users/objectpermission.html:33 users/forms/filtersets.py:70
+#: users/forms/model_forms.py:321
+msgid "Actions"
+msgstr ""
+
+#: templates/users/objectpermission.html:37
+msgid "View"
+msgstr ""
+
+#: templates/users/objectpermission.html:56 users/forms/model_forms.py:324
+msgid "Constraints"
+msgstr ""
+
+#: templates/users/objectpermission.html:76
+msgid "Assigned Users"
+msgstr ""
+
+#: templates/users/user.html:38
+msgid "Staff"
+msgstr ""
+
+#: templates/virtualization/cluster.html:56
+msgid "Allocated Resources"
+msgstr ""
+
+#: templates/virtualization/cluster.html:60
+#: templates/virtualization/virtualmachine.html:128
+msgid "Virtual CPUs"
+msgstr ""
+
+#: templates/virtualization/cluster.html:64
+#: templates/virtualization/virtualmachine.html:132
+msgid "Memory"
+msgstr ""
+
+#: templates/virtualization/cluster.html:74
+#: templates/virtualization/virtualmachine.html:142
+msgid "Disk Space"
+msgstr ""
+
+#: templates/virtualization/cluster.html:77
+#: templates/virtualization/virtualmachine.html:145
+msgctxt "Abbreviation for gigabyte"
+msgid "GB"
+msgstr ""
+
+#: templates/virtualization/cluster/base.html:18
+msgid "Add Virtual Machine"
+msgstr ""
+
+#: templates/virtualization/cluster/base.html:24
+msgid "Assign Device"
+msgstr ""
+
+#: templates/virtualization/cluster/devices.html:10
+msgid "Remove Selected"
+msgstr ""
+
+#: templates/virtualization/cluster_add_devices.html:9
+#, python-format
+msgid "Add Device to Cluster %(cluster)s"
+msgstr ""
+
+#: templates/virtualization/cluster_add_devices.html:23
+msgid "Device Selection"
+msgstr ""
+
+#: templates/virtualization/cluster_add_devices.html:31
+msgid "Add Devices"
+msgstr ""
+
+#: templates/virtualization/clustergroup.html:10
+#: templates/virtualization/clustertype.html:10
+msgid "Add Cluster"
+msgstr ""
+
+#: templates/virtualization/clustergroup.html:20
+#: virtualization/forms/model_forms.py:50
+msgid "Cluster Group"
+msgstr ""
+
+#: templates/virtualization/clustertype.html:20
+#: templates/virtualization/virtualmachine.html:111
+#: virtualization/forms/model_forms.py:34
+msgid "Cluster Type"
+msgstr ""
+
+#: templates/virtualization/virtualmachine.html:124
+#: virtualization/forms/bulk_edit.py:187
+#: virtualization/forms/model_forms.py:225
+msgid "Resources"
+msgstr ""
+
+#: templates/wireless/inc/authentication_attrs.html:13
+msgid "Cipher"
+msgstr ""
+
+#: templates/wireless/inc/authentication_attrs.html:17
+msgid "PSK"
+msgstr ""
+
+#: templates/wireless/inc/authentication_attrs.html:21
+msgid "Show Secret"
+msgstr ""
+
+#: templates/wireless/inc/wirelesslink_interface.html:35
+#: templates/wireless/inc/wirelesslink_interface.html:45
+msgctxt "Abbreviation for megahertz"
+msgid "MHz"
+msgstr ""
+
+#: templates/wireless/wirelesslan.html:11 wireless/forms/model_forms.py:54
+msgid "Wireless LAN"
+msgstr ""
+
+#: templates/wireless/wirelesslan.html:59
+msgid "Attached Interfaces"
+msgstr ""
+
+#: templates/wireless/wirelesslangroup.html:17
+msgid "Add Wireless LAN"
+msgstr ""
+
+#: templates/wireless/wirelesslangroup.html:26 wireless/forms/model_forms.py:27
+msgid "Wireless LAN Group"
+msgstr ""
+
+#: templates/wireless/wirelesslangroup.html:64
+msgid "Add Wireless LAN Group"
+msgstr ""
+
+#: templates/wireless/wirelesslink.html:16
+msgid "Link Properties"
+msgstr ""
+
+#: tenancy/choices.py:19
+msgid "Tertiary"
+msgstr ""
+
+#: tenancy/choices.py:20
+msgid "Inactive"
+msgstr ""
+
+#: tenancy/filtersets.py:30 tenancy/filtersets.py:56
+msgid "Contact group (ID)"
+msgstr ""
+
+#: tenancy/filtersets.py:36 tenancy/filtersets.py:63
+msgid "Contact group (slug)"
+msgstr ""
+
+#: tenancy/filtersets.py:92
+msgid "Contact (ID)"
+msgstr ""
+
+#: tenancy/filtersets.py:96
+msgid "Contact role (ID)"
+msgstr ""
+
+#: tenancy/filtersets.py:102
+msgid "Contact role (slug)"
+msgstr ""
+
+#: tenancy/filtersets.py:134
+msgid "Contact group"
+msgstr ""
+
+#: tenancy/filtersets.py:145 tenancy/filtersets.py:164
+msgid "Tenant group (ID)"
+msgstr ""
+
+#: tenancy/filtersets.py:197
+msgid "Tenant Group (ID)"
+msgstr ""
+
+#: tenancy/filtersets.py:204
+msgid "Tenant Group (slug)"
+msgstr ""
+
+#: tenancy/forms/bulk_edit.py:65
+msgid "Desciption"
+msgstr ""
+
+#: tenancy/forms/bulk_import.py:101
+msgid "Assigned contact"
+msgstr ""
+
+#: tenancy/models/contacts.py:31
+msgid "contact group"
+msgstr ""
+
+#: tenancy/models/contacts.py:32
+msgid "contact groups"
+msgstr ""
+
+#: tenancy/models/contacts.py:47
+msgid "contact role"
+msgstr ""
+
+#: tenancy/models/contacts.py:48
+msgid "contact roles"
+msgstr ""
+
+#: tenancy/models/contacts.py:67
+msgid "title"
+msgstr ""
+
+#: tenancy/models/contacts.py:72
+msgid "phone"
+msgstr ""
+
+#: tenancy/models/contacts.py:77
+msgid "email"
+msgstr ""
+
+#: tenancy/models/contacts.py:86
+msgid "link"
+msgstr ""
+
+#: tenancy/models/contacts.py:102
+msgid "contact"
+msgstr ""
+
+#: tenancy/models/contacts.py:103
+msgid "contacts"
+msgstr ""
+
+#: tenancy/models/contacts.py:149
+msgid "contact assignment"
+msgstr ""
+
+#: tenancy/models/contacts.py:150
+msgid "contact assignments"
+msgstr ""
+
+#: tenancy/models/tenants.py:32
+msgid "tenant group"
+msgstr ""
+
+#: tenancy/models/tenants.py:33
+msgid "tenant groups"
+msgstr ""
+
+#: tenancy/models/tenants.py:70
+msgid "Tenant name must be unique per group."
+msgstr ""
+
+#: tenancy/models/tenants.py:80
+msgid "Tenant slug must be unique per group."
+msgstr ""
+
+#: tenancy/models/tenants.py:88
+msgid "tenant"
+msgstr ""
+
+#: tenancy/models/tenants.py:89
+msgid "tenants"
+msgstr ""
+
+#: tenancy/tables/contacts.py:107
+msgid "Contact Title"
+msgstr ""
+
+#: tenancy/tables/contacts.py:111
+msgid "Contact Phone"
+msgstr ""
+
+#: tenancy/tables/contacts.py:115
+msgid "Contact Email"
+msgstr ""
+
+#: tenancy/tables/contacts.py:119
+msgid "Contact Address"
+msgstr ""
+
+#: tenancy/tables/contacts.py:123
+msgid "Contact Link"
+msgstr ""
+
+#: tenancy/tables/contacts.py:127
+msgid "Contact Description"
+msgstr ""
+
+#: users/filtersets.py:48 users/filtersets.py:151
+msgid "Group (name)"
+msgstr ""
+
+#: users/forms/bulk_edit.py:24
+msgid "First name"
+msgstr ""
+
+#: users/forms/bulk_edit.py:29
+msgid "Last name"
+msgstr ""
+
+#: users/forms/bulk_edit.py:41
+msgid "Staff status"
+msgstr ""
+
+#: users/forms/bulk_edit.py:46
+msgid "Superuser status"
+msgstr ""
+
+#: users/forms/bulk_import.py:43
+msgid "If no key is provided, one will be generated automatically."
+msgstr ""
+
+#: users/forms/filtersets.py:54 users/tables.py:42
+msgid "Is Staff"
+msgstr ""
+
+#: users/forms/filtersets.py:61 users/tables.py:45
+msgid "Is Superuser"
+msgstr ""
+
+#: users/forms/filtersets.py:94 users/tables.py:89
+msgid "Can View"
+msgstr ""
+
+#: users/forms/filtersets.py:101 users/tables.py:92
+msgid "Can Add"
+msgstr ""
+
+#: users/forms/filtersets.py:108 users/tables.py:95
+msgid "Can Change"
+msgstr ""
+
+#: users/forms/filtersets.py:115 users/tables.py:98
+msgid "Can Delete"
+msgstr ""
+
+#: users/forms/model_forms.py:58
+msgid "User Interface"
+msgstr ""
+
+#: users/forms/model_forms.py:115
+msgid ""
+"Keys must be at least 40 characters in length. Be sure to record "
+"your key prior to submitting this form, as it may no longer be "
+"accessible once the token has been created."
+msgstr ""
+
+#: users/forms/model_forms.py:127
+msgid ""
+"Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for "
+"no restrictions. Example: 10.1.1.0/24,192.168.10.16/32,2001:"
+"db8:1::/64
"
+msgstr ""
+
+#: users/forms/model_forms.py:176
+msgid "Confirm password"
+msgstr ""
+
+#: users/forms/model_forms.py:179
+msgid "Enter the same password as before, for verification."
+msgstr ""
+
+#: users/forms/model_forms.py:237
+msgid "Passwords do not match! Please check your input and try again."
+msgstr ""
+
+#: users/forms/model_forms.py:303
+msgid "Additional actions"
+msgstr ""
+
+#: users/forms/model_forms.py:306
+msgid "Actions granted in addition to those listed above"
+msgstr ""
+
+#: users/forms/model_forms.py:322
+msgid "Objects"
+msgstr ""
+
+#: users/forms/model_forms.py:334
+msgid ""
+"JSON expression of a queryset filter that will return only permitted "
+"objects. Leave null to match all objects of this type. A list of multiple "
+"objects will result in a logical OR operation."
+msgstr ""
+
+#: users/forms/model_forms.py:372
+msgid "At least one action must be selected."
+msgstr ""
+
+#: users/forms/model_forms.py:389
+#, python-brace-format
+msgid "Invalid filter for {model}: {error}"
+msgstr ""
+
+#: users/models.py:54
+msgid "user"
+msgstr ""
+
+#: users/models.py:55
+msgid "users"
+msgstr ""
+
+#: users/models.py:66
+msgid "A user with this username already exists."
+msgstr ""
+
+#: users/models.py:78
+msgid "group"
+msgstr ""
+
+#: users/models.py:79
+msgid "groups"
+msgstr ""
+
+#: users/models.py:104 users/models.py:105
+msgid "user preferences"
+msgstr ""
+
+#: users/models.py:172
+#, python-brace-format
+msgid "Key '{path}' is a leaf node; cannot assign new keys"
+msgstr ""
+
+#: users/models.py:184
+#, python-brace-format
+msgid "Key '{path}' is a dictionary; cannot assign a non-dictionary value"
+msgstr ""
+
+#: users/models.py:249
+msgid "expires"
+msgstr ""
+
+#: users/models.py:254
+msgid "last used"
+msgstr ""
+
+#: users/models.py:259
+msgid "key"
+msgstr ""
+
+#: users/models.py:265
+msgid "write enabled"
+msgstr ""
+
+#: users/models.py:267
+msgid "Permit create/update/delete operations using this key"
+msgstr ""
+
+#: users/models.py:278
+msgid "allowed IPs"
+msgstr ""
+
+#: users/models.py:280
+msgid ""
+"Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for "
+"no restrictions. Ex: \"10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64\""
+msgstr ""
+
+#: users/models.py:288
+msgid "token"
+msgstr ""
+
+#: users/models.py:289
+msgid "tokens"
+msgstr ""
+
+#: users/models.py:370
+msgid "The list of actions granted by this permission"
+msgstr ""
+
+#: users/models.py:375
+msgid "constraints"
+msgstr ""
+
+#: users/models.py:376
+msgid "Queryset filter matching the applicable objects of the selected type(s)"
+msgstr ""
+
+#: users/models.py:383
+msgid "permission"
+msgstr ""
+
+#: users/models.py:384
+msgid "permissions"
+msgstr ""
+
+#: users/tables.py:101
+msgid "Custom Actions"
+msgstr ""
+
+#: utilities/choices.py:16
+#, python-brace-format
+msgid "{name} has a key defined but CHOICES is not a list"
+msgstr ""
+
+#: utilities/choices.py:135
+msgid "Dark Red"
+msgstr ""
+
+#: utilities/choices.py:138
+msgid "Rose"
+msgstr ""
+
+#: utilities/choices.py:139
+msgid "Fuchsia"
+msgstr ""
+
+#: utilities/choices.py:141
+msgid "Dark Purple"
+msgstr ""
+
+#: utilities/choices.py:144
+msgid "Light Blue"
+msgstr ""
+
+#: utilities/choices.py:147
+msgid "Aqua"
+msgstr ""
+
+#: utilities/choices.py:148
+msgid "Dark Green"
+msgstr ""
+
+#: utilities/choices.py:150
+msgid "Light Green"
+msgstr ""
+
+#: utilities/choices.py:151
+msgid "Lime"
+msgstr ""
+
+#: utilities/choices.py:153
+msgid "Amber"
+msgstr ""
+
+#: utilities/choices.py:155
+msgid "Dark Orange"
+msgstr ""
+
+#: utilities/choices.py:156
+msgid "Brown"
+msgstr ""
+
+#: utilities/choices.py:157
+msgid "Light Grey"
+msgstr ""
+
+#: utilities/choices.py:158
+msgid "Grey"
+msgstr ""
+
+#: utilities/choices.py:159
+msgid "Dark Grey"
+msgstr ""
+
+#: utilities/choices.py:217
+msgid "Direct"
+msgstr ""
+
+#: utilities/choices.py:218
+msgid "Upload"
+msgstr ""
+
+#: utilities/choices.py:230 utilities/choices.py:244
+msgid "Auto-detect"
+msgstr ""
+
+#: utilities/choices.py:245
+msgid "Comma"
+msgstr ""
+
+#: utilities/choices.py:246
+msgid "Semicolon"
+msgstr ""
+
+#: utilities/choices.py:247
+msgid "Tab"
+msgstr ""
+
+#: utilities/fields.py:162
+#, python-format
+msgid ""
+"%s(%r) is invalid. to_model parameter to CounterCacheField must be a string "
+"in the format 'app.model'"
+msgstr ""
+
+#: utilities/fields.py:172
+#, python-format
+msgid ""
+"%s(%r) is invalid. to_field parameter to CounterCacheField must be a string "
+"in the format 'field'"
+msgstr ""
+
+#: utilities/forms/bulk_import.py:24
+msgid "Enter object data in CSV, JSON or YAML format."
+msgstr ""
+
+#: utilities/forms/bulk_import.py:37
+msgid "CSV delimiter"
+msgstr ""
+
+#: utilities/forms/bulk_import.py:38
+msgid "The character which delimits CSV fields. Applies only to CSV format."
+msgstr ""
+
+#: utilities/forms/bulk_import.py:101
+msgid "Unable to detect data format. Please specify."
+msgstr ""
+
+#: utilities/forms/bulk_import.py:124
+msgid "Invalid CSV delimiter"
+msgstr ""
+
+#: utilities/forms/bulk_import.py:168
+msgid ""
+"Invalid YAML data. Data must be in the form of multiple documents, or a "
+"single document comprising a list of dictionaries."
+msgstr ""
+
+#: utilities/forms/fields/array.py:17
+#, python-brace-format
+msgid ""
+"Invalid list ({value}). Must be numeric and ranges must be in ascending "
+"order."
+msgstr ""
+
+#: utilities/forms/fields/csv.py:44
+#, python-brace-format
+msgid "Invalid value for a multiple choice field: {value}"
+msgstr ""
+
+#: utilities/forms/fields/csv.py:57 utilities/forms/fields/csv.py:74
+#, python-format
+msgid "Object not found: %(value)s"
+msgstr ""
+
+#: utilities/forms/fields/csv.py:65
+#, python-brace-format
+msgid ""
+"\"{value}\" is not a unique value for this field; multiple objects were found"
+msgstr ""
+
+#: utilities/forms/fields/csv.py:97
+msgid "Object type must be specified as \"[ge,xe]-0/0/[0-9]"
+"code>)."
+msgstr ""
+
+#: utilities/forms/fields/expandable.py:46
+msgid ""
+"Specify a numeric range to create multiple IPs.
Example: 192.0.2."
+"[1,5,100-254]/24
"
+msgstr ""
+
+#: utilities/forms/fields/fields.py:31
+#, python-brace-format
+msgid ""
+" Markdown syntax is supported"
+msgstr ""
+
+#: utilities/forms/fields/fields.py:48
+msgid "URL-friendly unique shorthand"
+msgstr ""
+
+#: utilities/forms/fields/fields.py:99
+msgid "Enter context data in JSON format."
+msgstr ""
+
+#: utilities/forms/fields/fields.py:117
+msgid "MAC address must be in EUI-48 format"
+msgstr ""
+
+#: utilities/forms/forms.py:53
+msgid "Use regular expressions"
+msgstr ""
+
+#: utilities/forms/forms.py:87
+#, python-brace-format
+msgid "Unrecognized header: {name}"
+msgstr ""
+
+#: utilities/forms/forms.py:113
+msgid "Available Columns"
+msgstr ""
+
+#: utilities/forms/forms.py:121
+msgid "Selected Columns"
+msgstr ""
+
+#: utilities/forms/mixins.py:101
+msgid ""
+"This object has been modified since the form was rendered. Please consult "
+"the object's change log for details."
+msgstr ""
+
+#: utilities/templates/builtins/customfield_value.html:30
+msgid "Not defined"
+msgstr ""
+
+#: utilities/templates/buttons/bookmark.html:9
+msgid "Unbookmark"
+msgstr ""
+
+#: utilities/templates/buttons/bookmark.html:13
+msgid "Bookmark"
+msgstr ""
+
+#: utilities/templates/buttons/clone.html:4
+msgid "Clone"
+msgstr ""
+
+#: utilities/templates/buttons/export.html:4
+msgid "Export"
+msgstr ""
+
+#: utilities/templates/buttons/export.html:7
+msgid "Current View"
+msgstr ""
+
+#: utilities/templates/buttons/export.html:8
+msgid "All Data"
+msgstr ""
+
+#: utilities/templates/buttons/export.html:28
+msgid "Add export template"
+msgstr ""
+
+#: utilities/templates/buttons/import.html:4
+msgid "Import"
+msgstr ""
+
+#: utilities/templates/form_helpers/render_field.html:36
+msgid "Copy to clipboard"
+msgstr ""
+
+#: utilities/templates/form_helpers/render_field.html:52
+msgid "This field is required"
+msgstr ""
+
+#: utilities/templates/form_helpers/render_field.html:65
+msgid "Set Null"
+msgstr ""
+
+#: utilities/templates/helpers/applied_filters.html:11
+msgid "Clear all"
+msgstr ""
+
+#: utilities/templates/helpers/table_config_form.html:8
+msgid "Table Configuration"
+msgstr ""
+
+#: utilities/templates/helpers/table_config_form.html:31
+msgid "Move Up"
+msgstr ""
+
+#: utilities/templates/helpers/table_config_form.html:34
+msgid "Move Down"
+msgstr ""
+
+#: utilities/templates/widgets/apiselect.html:7
+msgid "Open selector"
+msgstr ""
+
+#: utilities/templates/widgets/clearable_file_input.html:12
+msgid "None assigned"
+msgstr ""
+
+#: utilities/templates/widgets/markdown_input.html:6
+msgid "Write"
+msgstr ""
+
+#: utilities/templates/widgets/markdown_input.html:20
+msgid "Testing"
+msgstr ""
+
+#: virtualization/filtersets.py:77
+msgid "Parent group (ID)"
+msgstr ""
+
+#: virtualization/filtersets.py:83
+msgid "Parent group (slug)"
+msgstr ""
+
+#: virtualization/filtersets.py:87 virtualization/filtersets.py:137
+msgid "Cluster type (ID)"
+msgstr ""
+
+#: virtualization/filtersets.py:126
+msgid "Cluster group (ID)"
+msgstr ""
+
+#: virtualization/filtersets.py:147 virtualization/filtersets.py:262
+msgid "Cluster (ID)"
+msgstr ""
+
+#: virtualization/forms/bulk_edit.py:163
+#: virtualization/models/virtualmachines.py:112
+msgid "vCPUs"
+msgstr ""
+
+#: virtualization/forms/bulk_edit.py:167
+msgid "Memory (MB)"
+msgstr ""
+
+#: virtualization/forms/bulk_edit.py:171
+msgid "Disk (GB)"
+msgstr ""
+
+#: virtualization/forms/bulk_import.py:43
+msgid "Type of cluster"
+msgstr ""
+
+#: virtualization/forms/bulk_import.py:50
+msgid "Assigned cluster group"
+msgstr ""
+
+#: virtualization/forms/bulk_import.py:95
+msgid "Assigned cluster"
+msgstr ""
+
+#: virtualization/forms/bulk_import.py:102
+msgid "Assigned device within cluster"
+msgstr ""
+
+#: virtualization/forms/model_forms.py:155
+#, python-brace-format
+msgid ""
+"{device} belongs to a different site ({device_site}) than the cluster "
+"({cluster_site})"
+msgstr ""
+
+#: virtualization/forms/model_forms.py:194
+msgid "Optionally pin this VM to a specific host device within the cluster"
+msgstr ""
+
+#: virtualization/forms/model_forms.py:222
+msgid "Site/Cluster"
+msgstr ""
+
+#: virtualization/models/clusters.py:25
+msgid "cluster type"
+msgstr ""
+
+#: virtualization/models/clusters.py:26
+msgid "cluster types"
+msgstr ""
+
+#: virtualization/models/clusters.py:45
+msgid "cluster group"
+msgstr ""
+
+#: virtualization/models/clusters.py:46
+msgid "cluster groups"
+msgstr ""
+
+#: virtualization/models/clusters.py:121
+msgid "cluster"
+msgstr ""
+
+#: virtualization/models/clusters.py:122
+msgid "clusters"
+msgstr ""
+
+#: virtualization/models/clusters.py:141
+#, python-brace-format
+msgid ""
+"{count} devices are assigned as hosts for this cluster but are not in site "
+"{site}"
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:120
+msgid "memory (MB)"
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:125
+msgid "disk (GB)"
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:154
+msgid "Virtual machine name must be unique per cluster."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:157
+msgid "virtual machine"
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:158
+msgid "virtual machines"
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:172
+msgid "A virtual machine must be assigned to a site and/or cluster."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:179
+#, python-brace-format
+msgid "The selected cluster ({cluster}) is not assigned to this site ({site})."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:186
+msgid "Must specify a cluster when assigning a host device."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:191
+#, python-brace-format
+msgid ""
+"The selected device ({device}) is not assigned to this cluster ({cluster})."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:204
+#, python-brace-format
+msgid "Must be an IPv{family} address. ({ip} is an IPv{version} address.)"
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:213
+#, python-brace-format
+msgid "The specified IP address ({ip}) is not assigned to this VM."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:331
+#, python-brace-format
+msgid ""
+"The selected parent interface ({parent}) belongs to a different virtual "
+"machine ({virtual_machine})."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:346
+#, python-brace-format
+msgid ""
+"The selected bridge interface ({bridge}) belongs to a different virtual "
+"machine ({virtual_machine})."
+msgstr ""
+
+#: virtualization/models/virtualmachines.py:357
+#, python-brace-format
+msgid ""
+"The untagged VLAN ({untagged_vlan}) must belong to the same site as the "
+"interface's parent virtual machine, or it must be global."
+msgstr ""
+
+#: wireless/choices.py:11
+msgid "Access point"
+msgstr ""
+
+#: wireless/choices.py:12
+msgid "Station"
+msgstr ""
+
+#: wireless/choices.py:467
+msgid "Open"
+msgstr ""
+
+#: wireless/choices.py:469
+msgid "WPA Personal (PSK)"
+msgstr ""
+
+#: wireless/choices.py:470
+msgid "WPA Enterprise"
+msgstr ""
+
+#: wireless/forms/bulk_edit.py:72 wireless/forms/bulk_edit.py:119
+#: wireless/forms/bulk_import.py:68 wireless/forms/bulk_import.py:71
+#: wireless/forms/bulk_import.py:110 wireless/forms/bulk_import.py:113
+#: wireless/forms/filtersets.py:58 wireless/forms/filtersets.py:92
+msgid "Authentication cipher"
+msgstr ""
+
+#: wireless/forms/bulk_edit.py:78 wireless/forms/bulk_edit.py:125
+#: wireless/forms/filtersets.py:63 wireless/forms/filtersets.py:97
+msgid "Pre-shared key"
+msgstr ""
+
+#: wireless/forms/bulk_import.py:52
+msgid "Bridged VLAN"
+msgstr ""
+
+#: wireless/forms/bulk_import.py:89 wireless/tables/wirelesslink.py:27
+msgid "Interface A"
+msgstr ""
+
+#: wireless/forms/bulk_import.py:93 wireless/tables/wirelesslink.py:36
+msgid "Interface B"
+msgstr ""
+
+#: wireless/forms/model_forms.py:158
+msgid "Side B"
+msgstr ""
+
+#: wireless/models.py:30
+msgid "authentication cipher"
+msgstr ""
+
+#: wireless/models.py:38
+msgid "pre-shared key"
+msgstr ""
+
+#: wireless/models.py:68
+msgid "wireless LAN group"
+msgstr ""
+
+#: wireless/models.py:69
+msgid "wireless LAN groups"
+msgstr ""
+
+#: wireless/models.py:115
+msgid "wireless LAN"
+msgstr ""
+
+#: wireless/models.py:143
+msgid "interface A"
+msgstr ""
+
+#: wireless/models.py:150
+msgid "interface B"
+msgstr ""
+
+#: wireless/models.py:198
+msgid "wireless link"
+msgstr ""
+
+#: wireless/models.py:199
+msgid "wireless links"
+msgstr ""
+
+#: wireless/models.py:216 wireless/models.py:222
+#, python-brace-format
+msgid "{type} is not a wireless interface."
+msgstr ""
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py
index 5fe84ad5f..b0a43ef22 100644
--- a/netbox/users/forms/model_forms.py
+++ b/netbox/users/forms/model_forms.py
@@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm):
help_text=_(
'Keys must be at least 40 characters in length. Be sure to record your key prior to '
'submitting this form, as it may no longer be accessible once the token has been created.'
+ ),
+ widget=forms.TextInput(
+ attrs={'data-clipboard': 'true'}
)
)
allowed_ips = SimpleArrayField(
@@ -383,5 +386,5 @@ class ObjectPermissionForm(BootstrapMixin, forms.ModelForm):
model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
except FieldError as e:
raise forms.ValidationError({
- 'constraints': _('Invalid filter for {model}: {e}').format(model=model, e=e)
+ 'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e)
})
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 80fd0dd09..1f8772704 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -169,7 +169,7 @@ class UserConfig(models.Model):
elif key in d:
err_path = '.'.join(path.split('.')[:i + 1])
raise TypeError(
- _("Key '{err_path}' is a leaf node; cannot assign new keys").format(err_path=err_path)
+ _("Key '{path}' is a leaf node; cannot assign new keys").format(path=err_path)
)
else:
d = d.setdefault(key, {})
diff --git a/netbox/users/tables.py b/netbox/users/tables.py
index 3b418715a..afb270568 100644
--- a/netbox/users/tables.py
+++ b/netbox/users/tables.py
@@ -52,7 +52,7 @@ class UserTable(NetBoxTable):
model = NetBoxUser
fields = (
'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
- 'is_superuser',
+ 'is_superuser', 'last_login',
)
default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')
diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py
index b6f97e309..77bfc03ca 100644
--- a/netbox/utilities/choices.py
+++ b/netbox/utilities/choices.py
@@ -1,6 +1,8 @@
from django.conf import settings
from django.utils.translation import gettext_lazy as _
+from .constants import CSV_DELIMITERS
+
class ChoiceSetMeta(type):
"""
@@ -230,3 +232,17 @@ class ImportFormatChoices(ChoiceSet):
(JSON, 'JSON'),
(YAML, 'YAML'),
]
+
+
+class CSVDelimiterChoices(ChoiceSet):
+ AUTO = 'auto'
+ COMMA = CSV_DELIMITERS['comma']
+ SEMICOLON = CSV_DELIMITERS['semicolon']
+ TAB = CSV_DELIMITERS['tab']
+
+ CHOICES = [
+ (AUTO, _('Auto-detect')),
+ (COMMA, _('Comma')),
+ (SEMICOLON, _('Semicolon')),
+ (TAB, _('Tab')),
+ ]
diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py
index 5c551a810..345894065 100644
--- a/netbox/utilities/constants.py
+++ b/netbox/utilities/constants.py
@@ -58,3 +58,14 @@ HTTP_REQUEST_META_SAFE_COPY = [
'SERVER_NAME',
'SERVER_PORT',
]
+
+
+#
+# CSV-style format delimiters
+#
+
+CSV_DELIMITERS = {
+ 'comma': ',',
+ 'semicolon': ';',
+ 'tab': '\t',
+}
diff --git a/netbox/utilities/counters.py b/netbox/utilities/counters.py
index 6c1418dff..0ee2606db 100644
--- a/netbox/utilities/counters.py
+++ b/netbox/utilities/counters.py
@@ -1,5 +1,5 @@
from django.apps import apps
-from django.db.models import F
+from django.db.models import F, Count, OuterRef, Subquery
from django.db.models.signals import post_delete, post_save
from netbox.registry import registry
@@ -23,6 +23,24 @@ def update_counter(model, pk, counter_name, value):
)
+def update_counts(model, field_name, related_query):
+ """
+ Perform a bulk update for the given model and counter field. For example,
+
+ update_counts(Device, '_interface_count', 'interfaces')
+
+ will effectively set
+
+ Device.objects.update(_interface_count=Count('interfaces'))
+ """
+ subquery = Subquery(
+ model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
+ )
+ return model.objects.update(**{
+ field_name: subquery
+ })
+
+
#
# Signal handlers
#
@@ -34,16 +52,17 @@ def post_save_receiver(sender, instance, created, **kwargs):
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
new_pk = getattr(instance, field_name, None)
- old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None
+ has_old_field = field_name in instance.tracker
+ old_pk = instance.tracker.get(field_name) if has_old_field else None
# Update the counters on the old and/or new parents as needed
if old_pk is not None:
update_counter(parent_model, old_pk, counter_name, -1)
- if new_pk is not None and (old_pk or created):
+ if new_pk is not None and (has_old_field or created):
update_counter(parent_model, new_pk, counter_name, 1)
-def post_delete_receiver(sender, instance, **kwargs):
+def post_delete_receiver(sender, instance, origin, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
"""
@@ -53,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs):
# Decrement the parent's counter by one
if parent_pk is not None:
- update_counter(parent_model, parent_pk, counter_name, -1)
+ # MPTT sends two delete signals for child elements so guard against multiple decrements
+ if not origin or origin == instance:
+ update_counter(parent_model, parent_pk, counter_name, -1)
#
diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py
index 1d3bdbafd..9af12ac2e 100644
--- a/netbox/utilities/error_handlers.py
+++ b/netbox/utilities/error_handlers.py
@@ -1,16 +1,26 @@
from django.contrib import messages
+from django.db.models import ProtectedError, RestrictedError
from django.utils.html import escape
from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
def handle_protectederror(obj_list, request, e):
"""
- Generate a user-friendly error message in response to a ProtectedError exception.
+ Generate a user-friendly error message in response to a ProtectedError or RestrictedError exception.
"""
- protected_objects = list(e.protected_objects)
- protected_count = len(protected_objects) if len(protected_objects) <= 50 else 'More than 50'
- err_message = f"Unable to delete {', '.join(str(obj) for obj in obj_list)}. " \
- f"{protected_count} dependent objects were found: "
+ if type(e) is ProtectedError:
+ protected_objects = list(e.protected_objects)
+ elif type(e) is RestrictedError:
+ protected_objects = list(e.restricted_objects)
+ else:
+ raise e
+
+ # Formulate the error message
+ err_message = _("Unable to delete {objects}. {count} dependent objects were found: ").format(
+ objects=', '.join(str(obj) for obj in obj_list),
+ count=len(protected_objects) if len(protected_objects) <= 50 else _('More than 50')
+ )
# Append dependent objects to error message
dependent_objects = []
diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py
index 6bdfd5662..57362d3dd 100644
--- a/netbox/utilities/forms/bulk_import.py
+++ b/netbox/utilities/forms/bulk_import.py
@@ -7,10 +7,10 @@ from django import forms
from django.utils.translation import gettext as _
from core.forms.mixins import SyncedDataMixin
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
+from utilities.constants import CSV_DELIMITERS
from utilities.forms.utils import parse_csv
from .mixins import BootstrapMixin
-from ..choices import ImportMethodChoices
class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
@@ -24,13 +24,20 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
help_text=_("Enter object data in CSV, JSON or YAML format.")
)
upload_file = forms.FileField(
- label="Data file",
+ label=_("Data file"),
required=False
)
format = forms.ChoiceField(
choices=ImportFormatChoices,
initial=ImportFormatChoices.AUTO
)
+ csv_delimiter = forms.ChoiceField(
+ choices=CSVDelimiterChoices,
+ initial=CSVDelimiterChoices.AUTO,
+ label=_("CSV delimiter"),
+ help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
+ required=False
+ )
data_field = 'data'
@@ -54,13 +61,18 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
# Determine the data format
if self.cleaned_data['format'] == ImportFormatChoices.AUTO:
- format = self._detect_format(data)
+ if self.cleaned_data['csv_delimiter'] != CSVDelimiterChoices.AUTO:
+ # Specifying the CSV delimiter implies CSV format
+ format = ImportFormatChoices.CSV
+ else:
+ format = self._detect_format(data)
else:
format = self.cleaned_data['format']
# Process data according to the selected format
if format == ImportFormatChoices.CSV:
- self.cleaned_data['data'] = self._clean_csv(data)
+ delimiter = self.cleaned_data.get('csv_delimiter', CSVDelimiterChoices.AUTO)
+ self.cleaned_data['data'] = self._clean_csv(data, delimiter=delimiter)
elif format == ImportFormatChoices.JSON:
self.cleaned_data['data'] = self._clean_json(data)
elif format == ImportFormatChoices.YAML:
@@ -78,7 +90,10 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
return ImportFormatChoices.JSON
if data.startswith('---') or data.startswith('- '):
return ImportFormatChoices.YAML
- if ',' in data.split('\n', 1)[0]:
+ # Look for any of the CSV delimiters in the first line (ignoring the default 'auto' choice)
+ first_line = data.split('\n', 1)[0]
+ csv_delimiters = CSV_DELIMITERS.values()
+ if any(x in first_line for x in csv_delimiters):
return ImportFormatChoices.CSV
except IndexError:
pass
@@ -86,15 +101,35 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
'format': _('Unable to detect data format. Please specify.')
})
- def _clean_csv(self, data):
+ def _clean_csv(self, data, delimiter=CSVDelimiterChoices.AUTO):
"""
Clean CSV-formatted data. The first row will be treated as column headers.
"""
+ # Determine the CSV dialect
+ if delimiter == CSVDelimiterChoices.AUTO:
+ # This uses a rough heuristic to detect the CSV dialect based on the presence of supported delimiting
+ # characters. If the data is malformed, we'll fall back to the default Excel dialect.
+ delimiters = ''.join(CSV_DELIMITERS.values())
+ try:
+ dialect = csv.Sniffer().sniff(data.strip(), delimiters=delimiters)
+ except csv.Error:
+ dialect = csv.excel
+ elif delimiter in (CSVDelimiterChoices.COMMA, CSVDelimiterChoices.SEMICOLON):
+ dialect = csv.excel
+ dialect.delimiter = delimiter
+ elif delimiter == CSVDelimiterChoices.TAB:
+ dialect = csv.excel_tab
+ else:
+ raise forms.ValidationError({
+ 'csv_delimiter': _('Invalid CSV delimiter'),
+ })
+
stream = StringIO(data.strip())
- reader = csv.reader(stream)
+ reader = csv.reader(stream, dialect=dialect)
headers, records = parse_csv(reader)
# Set CSV headers for reference by the model form
+ headers.pop('id', None)
self._csv_headers = headers
return records
diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py
index 9f84e100f..54c9e41cb 100644
--- a/netbox/utilities/forms/forms.py
+++ b/netbox/utilities/forms/forms.py
@@ -40,8 +40,11 @@ class BulkRenameForm(BootstrapMixin, forms.Form):
"""
An extendable form to be used for renaming objects in bulk.
"""
- find = forms.CharField()
+ find = forms.CharField(
+ strip=False
+ )
replace = forms.CharField(
+ strip=False,
required=False
)
use_regex = forms.BooleanField(
@@ -67,22 +70,24 @@ class CSVModelForm(forms.ModelForm):
"""
ModelForm used for the import of objects in CSV format.
"""
- def __init__(self, *args, headers=None, fields=None, **kwargs):
- headers = headers or {}
- fields = fields or []
+ def __init__(self, *args, headers=None, **kwargs):
+ self.headers = headers or {}
super().__init__(*args, **kwargs)
# Modify the model form to accommodate any customized to_field_name properties
- for field, to_field in headers.items():
+ for field, to_field in self.headers.items():
if to_field is not None:
self.fields[field].to_field_name = to_field
- # Omit any fields not specified (e.g. because the form is being used to
- # updated rather than create objects)
- if fields:
- for field in list(self.fields.keys()):
- if field not in fields:
- del self.fields[field]
+ def clean(self):
+ # Flag any invalid CSV headers
+ for header in self.headers:
+ if header not in self.fields:
+ raise forms.ValidationError(
+ _("Unrecognized header: {name}").format(name=header)
+ )
+
+ return super().clean()
class FilterForm(BootstrapMixin, forms.Form):
diff --git a/netbox/utilities/management/commands/calculate_cached_counts.py b/netbox/utilities/management/commands/calculate_cached_counts.py
index 62354797c..f7810604f 100644
--- a/netbox/utilities/management/commands/calculate_cached_counts.py
+++ b/netbox/utilities/management/commands/calculate_cached_counts.py
@@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand
from django.db.models import Count, OuterRef, Subquery
from netbox.registry import registry
+from utilities.counters import update_counts
class Command(BaseCommand):
@@ -26,27 +27,9 @@ class Command(BaseCommand):
return models
- def update_counts(self, model, field_name, related_query):
- """
- Perform a bulk update for the given model and counter field. For example,
-
- update_counts(Device, '_interface_count', 'interfaces')
-
- will effectively set
-
- Device.objects.update(_interface_count=Count('interfaces'))
- """
- self.stdout.write(f'Updating {model.__name__} {field_name}...')
- subquery = Subquery(
- model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
- )
- return model.objects.update(**{
- field_name: subquery
- })
-
def handle(self, *model_names, **options):
for model, mappings in self.collect_models().items():
for field_name, related_query in mappings.items():
- self.update_counts(model, field_name, related_query)
+ update_counts(model, field_name, related_query)
self.stdout.write(self.style.SUCCESS('Finished.'))
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
index bf6aa15a9..489b90f10 100644
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -1,8 +1,24 @@
__all__ = (
+ 'get_table_ordering',
'linkify_phone',
)
+def get_table_ordering(request, table):
+ """
+ Given a request, return the prescribed table ordering, if any. This may be necessary to determine prior to rendering
+ the table itself.
+ """
+ # Check for an explicit ordering
+ if 'sort' in request.GET:
+ return request.GET['sort'] or None
+
+ # Check for a configured preference
+ if request.user.is_authenticated:
+ if preference := request.user.config.get(f'tables.{table.__name__}.ordering'):
+ return preference
+
+
def linkify_phone(value):
"""
Render a telephone number as a hyperlink.
diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html
index 379dcc021..e5a564a3d 100644
--- a/netbox/utilities/templates/form_helpers/render_field.html
+++ b/netbox/utilities/templates/form_helpers/render_field.html
@@ -29,6 +29,14 @@
{{ label }}
+ {# Include a copy-to-clipboard button #}
+ {% elif 'data-clipboard' in field.field.widget.attrs %}
+
+ {{ field }}
+
+
{# Default field rendering #}
{% else %}
{{ field }}
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index 35aec1000..68541ae5a 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -1,6 +1,7 @@
from django import template
from django.http import QueryDict
+from extras.choices import CustomFieldTypeChoices
from utilities.utils import dict_to_querydict
__all__ = (
@@ -38,6 +39,11 @@ def customfield_value(customfield, value):
customfield: A CustomField instance
value: The custom field value applied to an object
"""
+ if value:
+ if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+ value = customfield.get_choice_label(value)
+ elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+ value = [customfield.get_choice_label(v) for v in value]
return {
'customfield': customfield,
'value': value,
diff --git a/netbox/extras/templatetags/plugins.py b/netbox/utilities/templatetags/plugins.py
similarity index 98%
rename from netbox/extras/templatetags/plugins.py
rename to netbox/utilities/templatetags/plugins.py
index 560d15e01..c429bed5f 100644
--- a/netbox/extras/templatetags/plugins.py
+++ b/netbox/utilities/templatetags/plugins.py
@@ -2,7 +2,7 @@ from django import template as template_
from django.conf import settings
from django.utils.safestring import mark_safe
-from extras.plugins import PluginTemplateExtension
+from netbox.plugins import PluginTemplateExtension
from netbox.registry import registry
register = template_.Library()
diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py
index 3c2dc3c45..0a84c5d1b 100644
--- a/netbox/utilities/testing/views.py
+++ b/netbox/utilities/testing/views.py
@@ -11,7 +11,7 @@ from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange
from netbox.models.features import ChangeLoggingMixin
from users.models import ObjectPermission
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from .base import ModelTestCase
from .utils import disable_warnings, post_data
@@ -580,7 +580,8 @@ class ViewTestCases:
def test_bulk_import_objects_without_permission(self):
data = {
'data': self._get_csv_data(),
- 'format': 'csv',
+ 'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Test GET without permission
@@ -597,7 +598,8 @@ class ViewTestCases:
initial_count = self._get_queryset().count()
data = {
'data': self._get_csv_data(),
- 'format': 'csv',
+ 'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign model-level permission
@@ -626,6 +628,7 @@ class ViewTestCases:
data = {
'format': ImportFormatChoices.CSV,
'data': csv_data,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign model-level permission
@@ -658,7 +661,8 @@ class ViewTestCases:
initial_count = self._get_queryset().count()
data = {
'data': self._get_csv_data(),
- 'format': 'csv',
+ 'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign constrained permission
diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py
index 0c61c0890..014c758e9 100644
--- a/netbox/utilities/tests/test_counters.py
+++ b/netbox/utilities/tests/test_counters.py
@@ -1,7 +1,11 @@
-from django.test import TestCase
+from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
+from django.urls import reverse
from dcim.models import *
-from utilities.testing.utils import create_test_device
+from users.models import ObjectPermission
+from utilities.testing.base import TestCase
+from utilities.testing.utils import create_test_device, create_test_user
class CountersTest(TestCase):
@@ -10,7 +14,6 @@ class CountersTest(TestCase):
"""
@classmethod
def setUpTestData(cls):
-
# Create devices
device1 = create_test_device('Device 1')
device2 = create_test_device('Device 2')
@@ -36,10 +39,18 @@ class CountersTest(TestCase):
self.assertEqual(device1.interface_count, 3)
self.assertEqual(device2.interface_count, 3)
+ # test saving an existing object - counter should not change
interface1.save()
device1.refresh_from_db()
self.assertEqual(device1.interface_count, 3)
+ # test save where tracked object FK back pointer is None
+ vc = VirtualChassis.objects.create(name='Virtual Chassis 1')
+ device1.virtual_chassis = vc
+ device1.save()
+ vc.refresh_from_db()
+ self.assertEqual(vc.member_count, 1)
+
def test_interface_count_deletion(self):
"""
When a tracked object (Interface) is deleted the tracking counter should be updated.
@@ -71,3 +82,25 @@ class CountersTest(TestCase):
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 3)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_mptt_child_delete(self):
+ device1, device2 = Device.objects.all()
+ inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
+ inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
+ device1.refresh_from_db()
+ self.assertEqual(device1.inventory_item_count, 2)
+
+ # Setup bulk_delete for the inventory items
+ self.add_permissions('dcim.delete_inventoryitem')
+ pk_list = device1.inventoryitems.values_list('pk', flat=True)
+ data = {
+ 'pk': pk_list,
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+
+ # Try POST with model-level permission
+ self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
+ device1.refresh_from_db()
+ self.assertEqual(device1.inventory_item_count, 0)
diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py
index 79ba3f4d8..d014d4bbd 100644
--- a/netbox/utilities/tests/test_forms.py
+++ b/netbox/utilities/tests/test_forms.py
@@ -3,6 +3,7 @@ from django.test import TestCase
from utilities.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm
+from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
@@ -331,3 +332,49 @@ class ImportFormTest(TestCase):
form._detect_format('')
with self.assertRaises(forms.ValidationError):
form._detect_format('?')
+
+ def test_csv_delimiters(self):
+ form = BulkImportForm()
+
+ data = (
+ "a,b,c\n"
+ "1,2,3\n"
+ "4,5,6\n"
+ )
+ self.assertEqual(form._clean_csv(data, delimiter=','), [
+ {'a': '1', 'b': '2', 'c': '3'},
+ {'a': '4', 'b': '5', 'c': '6'},
+ ])
+
+ data = (
+ "a;b;c\n"
+ "1;2;3\n"
+ "4;5;6\n"
+ )
+ self.assertEqual(form._clean_csv(data, delimiter=';'), [
+ {'a': '1', 'b': '2', 'c': '3'},
+ {'a': '4', 'b': '5', 'c': '6'},
+ ])
+
+ data = (
+ "a\tb\tc\n"
+ "1\t2\t3\n"
+ "4\t5\t6\n"
+ )
+ self.assertEqual(form._clean_csv(data, delimiter='\t'), [
+ {'a': '1', 'b': '2', 'c': '3'},
+ {'a': '4', 'b': '5', 'c': '6'},
+ ])
+
+
+class BulkRenameFormTest(TestCase):
+ def test_no_strip_whitespace(self):
+ # Tests to make sure Bulk Rename Form isn't stripping whitespaces
+ # See: https://github.com/netbox-community/netbox/issues/13791
+ form = BulkRenameForm(data={
+ "find": " hello ",
+ "replace": " world "
+ })
+ self.assertTrue(form.is_valid())
+ self.assertEqual(form.cleaned_data["find"], " hello ")
+ self.assertEqual(form.cleaned_data["replace"], " world ")
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 9524e242c..feb28c2d8 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -19,9 +19,9 @@ from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel
from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
-from extras.plugins import PluginConfig
from extras.utils import is_taggable
from netbox.config import get_config
+from netbox.plugins import PluginConfig
from urllib.parse import urlencode
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 5b9cf4117..04e8f2167 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -3,6 +3,7 @@ from rest_framework.routers import APIRootView
from dcim.models import Device
from extras.api.mixins import ConfigContextQuerySetMixin
from netbox.api.viewsets import NetBoxModelViewSet
+from utilities.query_functions import CollateAsChar
from utilities.utils import count_related
from virtualization import filtersets
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -87,3 +88,7 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filtersets.VMInterfaceFilterSet
brief_prefetch_fields = ['virtual_machine']
+
+ def get_bulk_destroy_queryset(self):
+ # Ensure child interfaces are deleted prior to their parents
+ return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name'))
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index 21dbc895a..73d4ca841 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/netbox/virtualization/forms/model_forms.py
@@ -151,8 +151,12 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
for device in self.cleaned_data.get('devices', []):
if device.site != self.cluster.site:
raise ValidationError({
- 'devices': _("{} belongs to a different site ({}) than the cluster ({})").format(
- device, device.site, self.cluster.site
+ 'devices': _(
+ "{device} belongs to a different site ({device_site}) than the cluster ({cluster_site})"
+ ).format(
+ device=device,
+ device_site=device.site,
+ cluster_site=self.cluster.site
)
})
diff --git a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
index 725b73573..abed09d7e 100644
--- a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
+++ b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.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_virtualmachine_counts(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
- vms = VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True))
-
- for vm in vms:
- vm.interface_count = vm._interface_count
-
- VirtualMachine.objects.bulk_update(vms, ['interface_count'], batch_size=100)
+ update_counts(VirtualMachine, 'interface_count', 'interfaces')
class Migration(migrations.Migration):
diff --git a/netbox/virtualization/migrations/0037_protect_child_interfaces.py b/netbox/virtualization/migrations/0037_protect_child_interfaces.py
new file mode 100644
index 000000000..ab6cf0cb3
--- /dev/null
+++ b/netbox/virtualization/migrations/0037_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 = [
+ ('virtualization', '0036_virtualmachine_config_template'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vminterface',
+ name='parent',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='virtualization.vminterface'),
+ ),
+ ]
diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py
index 6c8fd0c4b..f8acc4c36 100644
--- a/netbox/virtualization/models/clusters.py
+++ b/netbox/virtualization/models/clusters.py
@@ -135,10 +135,9 @@ class Cluster(ContactsMixin, PrimaryModel):
# If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
if self.pk and self.site:
- nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
- if nonsite_devices:
+ if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count():
raise ValidationError({
- 'site': _("{} devices are assigned as hosts for this cluster but are not in site {}").format(
- nonsite_devices, self.site
- )
+ 'site': _(
+ "{count} devices are assigned as hosts for this cluster but are not in site {site}"
+ ).format(count=nonsite_devices, site=self.site)
})
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index b2ae68860..3fb46fbb9 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -293,3 +293,29 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'vrf': vrfs[2].pk,
},
]
+
+ def test_bulk_delete_child_interfaces(self):
+ interface1 = VMInterface.objects.get(name='Interface 1')
+ virtual_machine = interface1.virtual_machine
+ self.add_permissions('virtualization.delete_vminterface')
+
+ # Create a child interface
+ child = VMInterface.objects.create(
+ virtual_machine=virtual_machine,
+ name='Interface 1A',
+ parent=interface1
+ )
+ self.assertEqual(virtual_machine.interfaces.count(), 4)
+
+ # Attempt to delete only the parent interface
+ url = self._get_detail_url(interface1)
+ self.client.delete(url, **self.header)
+ self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted
+
+ # Attempt to bulk delete parent & child together
+ data = [
+ {"id": interface1.pk},
+ {"id": child.pk},
+ ]
+ self.client.delete(self._get_list_url(), data, format='json', **self.header)
+ self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index a5d831d7e..f47c386e9 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -374,3 +374,32 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
}
+
+ def test_bulk_delete_child_interfaces(self):
+ interface1 = VMInterface.objects.get(name='Interface 1')
+ virtual_machine = interface1.virtual_machine
+ self.add_permissions('virtualization.delete_vminterface')
+
+ # Create a child interface
+ child = VMInterface.objects.create(
+ virtual_machine=virtual_machine,
+ name='Interface 1A',
+ parent=interface1
+ )
+ self.assertEqual(virtual_machine.interfaces.count(), 4)
+
+ # Attempt to delete only the parent interface
+ data = {
+ 'confirm': True,
+ }
+ self.client.post(self._get_url('delete', interface1), data)
+ self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted
+
+ # Attempt to bulk delete parent & child together
+ data = {
+ 'pk': [interface1.pk, child.pk],
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+ self.client.post(self._get_url('bulk_delete'), data)
+ self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 173d7047b..e8782243f 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -1,5 +1,4 @@
import traceback
-from collections import defaultdict
from django.contrib import messages
from django.db import transaction
@@ -16,8 +15,10 @@ from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
from ipam.models import IPAddress
from ipam.tables import InterfaceVLANTable
+from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from tenancy.views import ObjectContactsView
+from utilities.query_functions import CollateAsChar
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables
@@ -199,13 +200,13 @@ class ClusterDevicesView(generic.ObjectChildrenView):
table = DeviceTable
filterset = DeviceFilterSet
template_name = 'virtualization/cluster/devices.html'
- actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices')
- action_perms = defaultdict(set, **{
+ actions = {
'add': {'add'},
'import': {'add'},
+ 'export': {'view'},
'bulk_edit': {'change'},
'bulk_remove_devices': {'change'},
- })
+ }
tab = ViewTab(
label=_('Devices'),
badge=lambda obj: obj.devices.count(),
@@ -359,20 +360,16 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
table = tables.VirtualMachineVMInterfaceTable
filterset = filtersets.VMInterfaceFilterSet
template_name = 'virtualization/virtualmachine/interfaces.html'
+ actions = {
+ **DEFAULT_ACTION_PERMISSIONS,
+ 'bulk_rename': {'change'},
+ }
tab = ViewTab(
label=_('Interfaces'),
badge=lambda obj: obj.interface_count,
permission='virtualization.view_vminterface',
weight=500
)
- actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
- action_perms = defaultdict(set, **{
- 'add': {'add'},
- 'import': {'add'},
- 'bulk_edit': {'change'},
- 'bulk_delete': {'delete'},
- 'bulk_rename': {'change'},
- })
def get_children(self, request, parent):
return parent.interfaces.restrict(request.user, 'view').prefetch_related(
@@ -553,7 +550,8 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView):
class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
- queryset = VMInterface.objects.all()
+ # Ensure child interfaces are deleted prior to their parents
+ queryset = VMInterface.objects.order_by('virtual_machine', 'parent', CollateAsChar('_name'))
filterset = filtersets.VMInterfaceFilterSet
table = tables.VMInterfaceTable
diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py
index 1103cec37..a6cc9f535 100644
--- a/netbox/wireless/api/views.py
+++ b/netbox/wireless/api/views.py
@@ -1,6 +1,6 @@
from rest_framework.routers import APIRootView
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from wireless import filtersets
from wireless.models import *
from . import serializers
@@ -14,7 +14,7 @@ class WirelessRootView(APIRootView):
return 'Wireless'
-class WirelessLANGroupViewSet(NetBoxModelViewSet):
+class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
index 046918535..0b114f85f 100644
--- a/netbox/wireless/models.py
+++ b/netbox/wireless/models.py
@@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
@@ -214,14 +213,14 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
if self.interface_a.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'interface_a': _(
- "{type_display} is not a wireless interface."
- ).format(type_display=self.interface_a.get_type_display())
+ "{type} is not a wireless interface."
+ ).format(type=self.interface_a.get_type_display())
})
if self.interface_b.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'interface_a': _(
- "{type_display} is not a wireless interface."
- ).format(type_display=self.interface_b.get_type_display())
+ "{type} is not a wireless interface."
+ ).format(type=self.interface_b.get_type_display())
})
def save(self, *args, **kwargs):
diff --git a/requirements.txt b/requirements.txt
index 54f1334ed..9f9176ea2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,34 +1,35 @@
-bleach==6.0.0
-Django==4.2.5
-django-cors-headers==4.2.0
+bleach==6.1.0
+Django==4.2.6
+django-cors-headers==4.3.0
django-debug-toolbar==4.2.0
-django-filter==23.2
+django-filter==23.3
django-graphiql-debug-toolbar==0.2.0
-django-mptt==0.14
+django-mptt==0.14.0
django-pglocks==1.0.4
django-prometheus==2.3.1
-django-redis==5.3.0
-django-rich==1.7.0
+django-redis==5.4.0
+django-rich==1.8.0
django-rq==2.8.1
django-tables2==2.6.0
django-taggit==4.0.0
-django-timezone-field==6.0
+django-timezone-field==6.0.1
djangorestframework==3.14.0
-drf-spectacular==0.26.4
-drf-spectacular-sidecar==2023.9.1
+drf-spectacular==0.26.5
+drf-spectacular-sidecar==2023.10.1
feedparser==6.0.10
graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.2.7
+mkdocs-material==9.4.6
mkdocstrings[python-legacy]==0.23.0
-netaddr==0.8.0
-Pillow==10.0.0
-psycopg[binary,pool]==3.1.10
+netaddr==0.9.0
+Pillow==10.1.0
+psycopg[binary,pool]==3.1.12
PyYAML==6.0.1
-sentry-sdk==1.30.0
-social-auth-app-django==5.3.0
+requests==2.31.0
+sentry-sdk==1.32.0
+social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3
tablib==3.5.0