From 03535ce50b7063414dea71f753305d27506a646a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 8 Apr 2022 16:00:33 -0400 Subject: [PATCH 001/245] Closes #8995: Enable arbitrary ordering of REST API results --- docs/release-notes/version-3.3.md | 7 ++++ docs/rest-api/filtering.md | 20 +++++++++++ netbox/netbox/settings.py | 3 +- netbox/utilities/tests/test_api.py | 58 ++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 docs/release-notes/version-3.3.md diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md new file mode 100644 index 000000000..de59d0327 --- /dev/null +++ b/docs/release-notes/version-3.3.md @@ -0,0 +1,7 @@ +# NetBox v3.3 + +## v3.3.0 (FUTURE) + +### Enhancements + +* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md index 45dfcfa36..7ddda6f3c 100644 --- a/docs/rest-api/filtering.md +++ b/docs/rest-api/filtering.md @@ -106,3 +106,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it ```no-highlight GET /api/ipam/vlans/?group_id__n=3203 ``` + +## Ordering Objects + +To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values: + +```no-highlight +GET /api/dcim/sites/?ordering=facility +``` + +To invert the ordering, prepend a hyphen to the field name: + +```no-highlight +GET /api/dcim/sites/?ordering=-facility +``` + +Multiple fields can be specified by separating the field names with a comma. For example: + +```no-highlight +GET /api/dcim/sites/?ordering=facility,-name +``` diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 04c0e9c3d..0db41373f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -26,7 +26,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.1-dev' +VERSION = '3.3.0-dev' # Hostname HOSTNAME = platform.node() @@ -469,6 +469,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter', ), 'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 1171bd496..e341442be 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -176,6 +176,64 @@ class APIPaginationTestCase(APITestCase): self.assertEqual(len(response.data['results']), 100) +class APIOrderingTestCase(APITestCase): + user_permissions = ('dcim.view_site',) + + @classmethod + def setUpTestData(cls): + cls.url = reverse('dcim-api:site-list') + + sites = ( + Site(name='Site 1', slug='site-1', facility='C', description='Z'), + Site(name='Site 2', slug='site-2', facility='C', description='Y'), + Site(name='Site 3', slug='site-3', facility='B', description='X'), + Site(name='Site 4', slug='site-4', facility='B', description='W'), + Site(name='Site 5', slug='site-5', facility='A', description='V'), + Site(name='Site 6', slug='site-6', facility='A', description='U'), + ) + Site.objects.bulk_create(sites) + + def test_default_order(self): + response = self.client.get(self.url, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 1', 'Site 2', 'Site 3', 'Site 4', 'Site 5', 'Site 6'] + ) + + def test_order_single_field(self): + response = self.client.get(f'{self.url}?ordering=description', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1'] + ) + + def test_order_reversed(self): + response = self.client.get(f'{self.url}?ordering=-name', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1'] + ) + + def test_order_multiple_fields(self): + response = self.client.get(f'{self.url}?ordering=facility,name', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 5', 'Site 6', 'Site 3', 'Site 4', 'Site 1', 'Site 2'] + ) + + class APIDocsTestCase(TestCase): def setUp(self): From 17df8a5c4394785cb2e176788e468bd508c5a169 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Apr 2022 14:45:28 -0400 Subject: [PATCH 002/245] Closes #8495: Enable custom field grouping --- docs/release-notes/version-3.3.md | 6 ++ netbox/extras/api/serializers.py | 4 +- netbox/extras/filtersets.py | 3 +- netbox/extras/forms/bulk_edit.py | 5 +- netbox/extras/forms/bulk_import.py | 4 +- netbox/extras/forms/filtersets.py | 5 +- netbox/extras/forms/models.py | 4 +- .../migrations/0074_customfield_group_name.py | 22 +++++ netbox/extras/models/customfields.py | 7 +- netbox/extras/tables/tables.py | 4 +- netbox/netbox/models/features.py | 12 +++ netbox/templates/extras/customfield.html | 4 + .../templates/inc/panels/custom_fields.html | 95 ++++++++++--------- 13 files changed, 119 insertions(+), 56 deletions(-) create mode 100644 netbox/extras/migrations/0074_customfield_group_name.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index de59d0327..1dd19a5c0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,4 +4,10 @@ ### Enhancements +* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results + +### REST API Changes + +* extras.CustomField + * Added `group_name` field diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e05d4083c..eed7f7603 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -88,8 +88,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description', - 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', + 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', + 'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 25477fbda..467ae23af 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description'] + fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -70,6 +70,7 @@ class CustomFieldFilterSet(BaseFilterSet): return queryset.filter( Q(name__icontains=value) | Q(label__icontains=value) | + Q(group_name__icontains=value) | Q(description__icontains=value) ) diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index e16f8aeac..b722bd751 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -24,6 +24,9 @@ class CustomFieldBulkEditForm(BulkEditForm): queryset=CustomField.objects.all(), widget=forms.MultipleHiddenInput ) + group_name = forms.CharField( + required=False + ) description = forms.CharField( required=False ) @@ -35,7 +38,7 @@ class CustomFieldBulkEditForm(BulkEditForm): required=False ) - nullable_fields = ('description',) + nullable_fields = ('group_name', 'description',) class CustomLinkBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index fa6d8af55..dabf2f811 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -36,8 +36,8 @@ class CustomFieldCSVForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', - 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', + 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 5d66c8be8..1710ecb89 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -32,7 +32,7 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('type', 'content_types', 'weight', 'required')), + ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), @@ -44,6 +44,9 @@ class CustomFieldFilterForm(FilterForm): required=False, label=_('Field type') ) + group_name = forms.CharField( + required=False + ) weight = forms.IntegerField( required=False ) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 112911f42..b07853f86 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -40,7 +40,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): ) fieldsets = ( - ('Custom Field', ('content_types', 'name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), + ('Custom Field', ( + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', + )), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), diff --git a/netbox/extras/migrations/0074_customfield_group_name.py b/netbox/extras/migrations/0074_customfield_group_name.py new file mode 100644 index 000000000..e1be76b1f --- /dev/null +++ b/netbox/extras/migrations/0074_customfield_group_name.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.4 on 2022-04-15 17:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0073_journalentry_tags_custom_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customfield', + options={'ordering': ['group_name', 'weight', 'name']}, + ), + migrations.AddField( + model_name='customfield', + name='group_name', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 49afe1bba..55caa4a70 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -79,6 +79,11 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): help_text='Name of the field as displayed to users (if not provided, ' 'the field\'s name will be used)' ) + group_name = models.CharField( + max_length=50, + blank=True, + help_text="Custom fields within the same group will be displayed together" + ) description = models.CharField( max_length=200, blank=True @@ -134,7 +139,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): objects = CustomFieldManager() class Meta: - ordering = ['weight', 'name'] + ordering = ['group_name', 'weight', 'name'] def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index a13054d56..1a0f5d58a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -32,10 +32,10 @@ class CustomFieldTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = CustomField fields = ( - 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', + 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', 'description', 'filter_logic', 'choices', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description') + default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') # diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e443dde5f..4bd1b0e9c 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -117,6 +119,16 @@ class CustomFieldsMixin(models.Model): return data + def get_custom_fields_by_group(self): + """ + Return a dictionary of custom field/value mappings organized by group. + """ + grouped_custom_fields = defaultdict(dict) + for cf, value in self.get_custom_fields().items(): + grouped_custom_fields[cf.group_name][cf] = value + + return dict(grouped_custom_fields) + def clean(self): super().clean() from extras.models import CustomField diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 9be7a485a..0d9856938 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -19,6 +19,10 @@ Label {{ object.label|placeholder }} + + Group Name + {{ object.group_name|placeholder }} + Type {{ object.get_type_display }} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 32e586d3a..b18d44030 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -1,49 +1,54 @@ {% load helpers %} -{% with custom_fields=object.get_custom_fields %} - {% if custom_fields %} -
-
Custom Fields
-
- - {% for field, value in custom_fields.items %} - - - - +{% with custom_fields=object.get_custom_fields_by_group %} + {% if custom_fields %} +
+
Custom Fields
+
+ {% for group_name, fields in custom_fields.items %} + {% if group_name %} +
{{ group_name }}
+ {% endif %} +
- {{ field }} - - {% if field.type == 'integer' and value is not None %} - {{ value }} - {% elif field.type == 'longtext' and value %} - {{ value|markdown }} - {% elif field.type == 'boolean' and value == True %} - {% checkmark value true="True" %} - {% elif field.type == 'boolean' and value == False %} - {% checkmark value false="False" %} - {% elif field.type == 'url' and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 'json' and value %} -
{{ value|json }}
- {% elif field.type == 'multiselect' and value %} - {{ value|join:", " }} - {% elif field.type == 'object' and value %} - {{ value|linkify }} - {% elif field.type == 'multiobject' and value %} - {% for obj in value %} - {{ obj|linkify }}{% if not forloop.last %}
{% endif %} - {% endfor %} - {% elif value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - - {% endif %} -
+ {% for field, value in fields.items %} + + +
+ {{ field }} + + {% if field.type == 'integer' and value is not None %} + {{ value }} + {% elif field.type == 'longtext' and value %} + {{ value|markdown }} + {% elif field.type == 'boolean' and value == True %} + {% checkmark value true="True" %} + {% elif field.type == 'boolean' and value == False %} + {% checkmark value false="False" %} + {% elif field.type == 'url' and value %} + {{ value|truncatechars:70 }} + {% elif field.type == 'json' and value %} +
{{ value|json }}
+ {% elif field.type == 'multiselect' and value %} + {{ value|join:", " }} + {% elif field.type == 'object' and value %} + {{ value|linkify }} + {% elif field.type == 'multiobject' and value %} + {% for obj in value %} + {{ obj|linkify }}{% if not forloop.last %}
{% endif %} {% endfor %} -
-
-
- {% endif %} + {% elif value %} + {{ value }} + {% elif field.required %} + Not defined + {% else %} + {{ ''|placeholder }} + {% endif %} + + + {% endfor %} + + {% endfor %} + + + {% endif %} {% endwith %} From 25877202983182fd12a7b6a31dedca7edf5a589c Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 14:44:35 +0200 Subject: [PATCH 003/245] Fix 8878: Restrict API key usage by Source IP --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/api/authentication.py | 26 ++++++++++++++++++ netbox/templates/users/api_tokens.html | 15 ++++++++--- netbox/users/admin/__init__.py | 6 ++++- netbox/users/admin/forms.py | 2 +- netbox/users/api/serializers.py | 2 +- netbox/users/forms.py | 10 ++++++- .../migrations/0003_token_allowed_ips.py | 20 ++++++++++++++ netbox/users/models.py | 27 +++++++++++++++++++ 9 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 netbox/users/migrations/0003_token_allowed_ips.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1dd19a5c0..09dcfcf22 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +* [#8878](https://github.com/netbox-community/netbox/issues/8878) - Restrict API key usage by source IP ### REST API Changes diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 5e177bfcb..2f86a1da2 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.exceptions import ValidationError from rest_framework import authentication, exceptions from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS @@ -11,6 +12,31 @@ class TokenAuthentication(authentication.TokenAuthentication): """ model = Token + def authenticate(self, request): + authenticationresult = super().authenticate(request) + if authenticationresult: + token_user, token = authenticationresult + + # Verify source IP is allowed + if token.allowed_ips: + # Replace 'HTTP_X_REAL_IP' with the settings variable choosen in #8867 + if 'HTTP_X_REAL_IP' in request.META: + clientip = request.META['HTTP_X_REAL_IP'].split(",")[0].strip() + http_header = 'HTTP_X_REAL_IP' + elif 'REMOTE_ADDR' in request.META: + clientip = request.META['REMOTE_ADDR'] + http_header = 'REMOTE_ADDR' + else: + raise exceptions.AuthenticationFailed(f"A HTTP header containing the SourceIP (HTTP_X_REAL_IP, REMOTE_ADDR) is missing from the request.") + + try: + if not token.validate_client_ip(clientip): + raise exceptions.AuthenticationFailed(f"Source IP {clientip} is not allowed to use this token.") + except ValidationError as ValidationErrorInfo: + raise exceptions.ValidationError(f"The value in the HTTP Header {http_header} has a ValidationError: {ValidationErrorInfo.message}") + + return authenticationresult + def authenticate_credentials(self, key): model = self.get_model() try: diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 01ffec23a..360e65a67 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -22,11 +22,11 @@
-
+
Created
{{ token.created|annotated_date }}
-
+
Expires
{% if token.expires %} {{ token.expires|annotated_date }} @@ -34,7 +34,7 @@ Never {% endif %}
-
+
Create/Edit/Delete Operations
{% if token.write_enabled %} Enabled @@ -42,7 +42,14 @@ Disabled {% endif %}
-
+
+ Allowed Source IPs
+ {% if token.allowed_ips %} + {{ token.allowed_ips|join:', ' }} + {% else %} + Any + {% endif %} +
{% if token.description %}
{{ token.description }} {% endif %} diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 1b163ed06..ede26cd1b 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_): class TokenAdmin(admin.ModelAdmin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + 'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'list_allowed_ips' ] + def list_allowed_ips(self, obj): + return obj.allowed_ips or 'Any' + list_allowed_ips.short_description = "Allowed IPs" + # # Permissions diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 7d0212441..bc3d44862 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm): class Meta: fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description' + 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' ] model = Token diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index d490e8fe9..4b1f5bff3 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -62,7 +62,7 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description') + fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', 'allowed_ips') def to_internal_value(self, data): if 'key' not in data: diff --git a/netbox/users/forms.py b/netbox/users/forms.py index d5e6218e5..9720f92b7 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe +from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict @@ -100,10 +102,16 @@ class TokenForm(BootstrapMixin, forms.ModelForm): help_text="If no key is provided, one will be generated automatically." ) + allowed_ips = SimpleArrayField( + base_field=IPNetworkFormField(), + required=False, + help_text='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"', + ) + class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', + 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), diff --git a/netbox/users/migrations/0003_token_allowed_ips.py b/netbox/users/migrations/0003_token_allowed_ips.py new file mode 100644 index 000000000..f4eaa9f96 --- /dev/null +++ b/netbox/users/migrations/0003_token_allowed_ips.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-04-19 12:37 + +import django.contrib.postgres.fields +from django.db import migrations +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='allowed_ips', + field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 722ec5ba6..40ff78b98 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,17 +4,20 @@ import os from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * +import ipaddress __all__ = ( 'ObjectPermission', @@ -216,6 +219,12 @@ class Token(models.Model): max_length=200, blank=True ) + allowed_ips = ArrayField( + base_field=IPNetworkField(), + blank=True, + null=True, + help_text='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"', + ) class Meta: pass @@ -240,6 +249,24 @@ class Token(models.Model): return False return True + def validate_client_ip(self, raw_ip_address): + """ + Checks that an IP address falls within the allowed IPs. + """ + if not self.allowed_ips: + return True + + try: + ip_address = ipaddress.ip_address(raw_ip_address) + except ValueError as e: + raise ValidationError(str(e)) + + for ip_network in self.allowed_ips: + if ip_address in ipaddress.ip_network(ip_network): + return True + + return False + # # Permissions From 086e34f728c4fb873b7e63561bc901e9954a5ec3 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:33:29 +0200 Subject: [PATCH 004/245] Updated docs relnotes to refer to 8233 --- docs/release-notes/version-3.3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 09dcfcf22..415e61963 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,7 +6,7 @@ * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results -* [#8878](https://github.com/netbox-community/netbox/issues/8878) - Restrict API key usage by source IP +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP ### REST API Changes From fa4807be8ccf93aed93c42dbcb5231e7657c8e54 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:55:39 +0200 Subject: [PATCH 005/245] Update releasenotes --- docs/release-notes/version-3.3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 415e61963..294f8f4d7 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,7 +6,7 @@ * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results -* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP ### REST API Changes From 61d756c7c48d67417255d3ca277c09abfe147bcc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Apr 2022 13:09:39 -0400 Subject: [PATCH 006/245] Closes #9261: NetBoxTable no longer automatically clears pre-existing calls to prefetch_related() on its queryset --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/tables/tables.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1dd19a5c0..9b061b7d6 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -7,6 +7,10 @@ * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +### Other Changes + +* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset + ### REST API Changes * extras.CustomField diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8c5fb039c..5ebb78865 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -97,7 +97,7 @@ class BaseTable(tables.Table): break if prefetch_path: prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + self.data.data = self.data.data.prefetch_related(*prefetch_fields) def _get_columns(self, visible=True): columns = [] From e2a02de6e914cc6fd37c7c67570ddaaa88f19380 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 16:13:35 -0400 Subject: [PATCH 007/245] Remove erroneous field from prefetch --- netbox/ipam/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 79804aabd..078848b3e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -585,7 +585,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', + 'vrf', 'tenant', ) def get_extra_context(self, request, instance): From 01d2ede097b8f908f8a57302d868ae0e50ef4dcb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 16:22:07 -0400 Subject: [PATCH 008/245] Closes #1202: Support overlapping assignment of NAT IP addresses --- docs/release-notes/version-3.3.md | 8 ++++++++ netbox/ipam/api/serializers.py | 3 +-- .../0058_ipaddress_nat_inside_nonunique.py | 17 +++++++++++++++++ netbox/ipam/models/ip.py | 2 +- netbox/templates/ipam/ipaddress.html | 10 ++++++++-- 5 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 9b061b7d6..c46fceea5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,8 +2,13 @@ ## v3.3.0 (FUTURE) +### Breaking Changes + +* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). + ### Enhancements +* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results @@ -15,3 +20,6 @@ * extras.CustomField * Added `group_name` field +* ipam.IPAddress + * The `nat_inside` field no longer requires a unique value + * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3fa1bcc7e..ea5c37f91 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -360,7 +360,7 @@ class IPAddressSerializer(NetBoxModelSerializer): ) assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) - nat_outside = NestedIPAddressSerializer(required=False, read_only=True) + nat_outside = NestedIPAddressSerializer(many=True, read_only=True) class Meta: model = IPAddress @@ -369,7 +369,6 @@ class IPAddressSerializer(NetBoxModelSerializer): 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family', 'nat_outside'] @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_assigned_object(self, obj): diff --git a/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py new file mode 100644 index 000000000..63e93d137 --- /dev/null +++ b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.ipaddress'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a3b8fb2c1..db662f49c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -813,7 +813,7 @@ class IPAddress(NetBoxModel): ct_field='assigned_object_type', fk_field='assigned_object_id' ) - nat_inside = models.OneToOneField( + nat_inside = models.ForeignKey( to='self', on_delete=models.SET_NULL, related_name='nat_outside', diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7867e829b..96a76cf8c 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -91,8 +91,14 @@ - NAT (outside) - {{ object.nat_outside|linkify|placeholder }} + Outside NAT IPs + + {% for ip in object.nat_outside.all %} + {{ ip|linkify }}
+ {% empty %} + {{ ''|placeholder }} + {% endfor %} +
From c48c8cc353efc21a20a7c67fa435c19619ef6359 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 14:05:44 -0400 Subject: [PATCH 009/245] Remove termination IDs from cable creation view paths --- netbox/circuits/urls.py | 2 +- netbox/dcim/forms/connections.py | 3 +- netbox/dcim/tables/template_code.py | 50 +++++++++---------- netbox/dcim/urls.py | 16 +++--- netbox/dcim/views.py | 26 +++++----- .../circuits/inc/circuit_termination.html | 8 +-- netbox/templates/dcim/consoleport.html | 6 +-- netbox/templates/dcim/consoleserverport.html | 6 +-- netbox/templates/dcim/frontport.html | 12 ++--- netbox/templates/dcim/interface.html | 8 +-- netbox/templates/dcim/powerfeed.html | 2 +- netbox/templates/dcim/poweroutlet.html | 2 +- netbox/templates/dcim/powerport.html | 4 +- netbox/templates/dcim/rearport.html | 8 +-- 14 files changed, 76 insertions(+), 77 deletions(-) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index f3ee64cf0..894be27f3 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -60,7 +60,7 @@ urlpatterns = [ path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path('circuit-terminations//trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), + path('circuit-terminations/connect/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), ] diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 1ba7adf84..13ab0ae09 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,9 +1,8 @@ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * -from extras.models import Tag from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect +from utilities.forms import DynamicModelChoiceField, StaticSelect __all__ = ( 'ConnectCableToCircuitTerminationForm', diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 92739c6ed..0c1e0ed9e 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -133,9 +133,9 @@ CONSOLEPORT_BUTTONS = """ {% else %} @@ -165,9 +165,9 @@ CONSOLESERVERPORT_BUTTONS = """ {% else %} @@ -197,8 +197,8 @@ POWERPORT_BUTTONS = """ {% else %} @@ -224,7 +224,7 @@ POWEROUTLET_BUTTONS = """ {% if not record.mark_connected %} - + {% else %} @@ -274,10 +274,10 @@ INTERFACE_BUTTONS = """ {% else %} @@ -313,12 +313,12 @@ FRONTPORT_BUTTONS = """ {% else %} @@ -350,12 +350,12 @@ REARPORT_BUTTONS = """ {% else %} diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c5cd0fa65..0c04c734e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -294,7 +294,7 @@ urlpatterns = [ path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), - path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path('console-ports/connect/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports @@ -310,7 +310,7 @@ urlpatterns = [ path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), - path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path('console-server-ports/connect/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports @@ -326,7 +326,7 @@ urlpatterns = [ path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), - path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path('power-ports/connect/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets @@ -342,7 +342,7 @@ urlpatterns = [ path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), - path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path('power-outlets/connect/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces @@ -358,7 +358,7 @@ urlpatterns = [ path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), - path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), + path('interfaces/connect/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports @@ -374,7 +374,7 @@ urlpatterns = [ path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), - path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path('front-ports/connect/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports @@ -390,7 +390,7 @@ urlpatterns = [ path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), - path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path('rear-ports/connect/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Module bays @@ -500,6 +500,6 @@ urlpatterns = [ path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), - path('power-feeds//connect//', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}), + path('power-feeds/connect/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 57e8b1c79..b18b6d4b3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2812,16 +2812,16 @@ class CableCreateView(generic.ObjectEditView): # Set the form class based on the type of component being connected self.form = { - 'console-port': forms.ConnectCableToConsolePortForm, - 'console-server-port': forms.ConnectCableToConsoleServerPortForm, - 'power-port': forms.ConnectCableToPowerPortForm, - 'power-outlet': forms.ConnectCableToPowerOutletForm, - 'interface': forms.ConnectCableToInterfaceForm, - 'front-port': forms.ConnectCableToFrontPortForm, - 'rear-port': forms.ConnectCableToRearPortForm, - 'power-feed': forms.ConnectCableToPowerFeedForm, - 'circuit-termination': forms.ConnectCableToCircuitTerminationForm, - }[kwargs.get('termination_b_type')] + 'dcim.consoleport': forms.ConnectCableToConsolePortForm, + 'dcim.consoleserverport': forms.ConnectCableToConsoleServerPortForm, + 'dcim.powerport': forms.ConnectCableToPowerPortForm, + 'dcim.poweroutlet': forms.ConnectCableToPowerOutletForm, + 'dcim.interface': forms.ConnectCableToInterfaceForm, + 'dcim.frontport': forms.ConnectCableToFrontPortForm, + 'dcim.rearport': forms.ConnectCableToRearPortForm, + 'dcim.powerfeed': forms.ConnectCableToPowerFeedForm, + 'circuits.circuittermination': forms.ConnectCableToCircuitTerminationForm, + }[request.GET.get('termination_b_type')] return super().dispatch(request, *args, **kwargs) @@ -2831,9 +2831,9 @@ class CableCreateView(generic.ObjectEditView): def alter_object(self, obj, request, url_args, url_kwargs): termination_a_type = url_kwargs.get('termination_a_type') - termination_a_id = url_kwargs.get('termination_a_id') - termination_b_type_name = url_kwargs.get('termination_b_type') - self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) + termination_a_id = request.GET.get('termination_a_id') + app_label, model = request.GET.get('termination_b_type').split('.') + self.termination_b_type = ContentType.objects.get(app_label=app_label, model=model) # Initialize Cable termination attributes obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index fdb01e803..12fb85d57 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -70,10 +70,10 @@ Connect
{% endif %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index ce2c1655d..f2ebc16d9 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -113,7 +113,7 @@
  • Console Server Port @@ -121,7 +121,7 @@
  • Front Port @@ -129,7 +129,7 @@
  • Rear Port diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 52b1a3229..47b784ede 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -115,7 +115,7 @@
  • Console Port @@ -123,7 +123,7 @@
  • Front Port @@ -131,7 +131,7 @@
  • Rear Port diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 891f217ee..75402e2e7 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -105,22 +105,22 @@
  • diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 358922730..7181d2554 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -251,22 +251,22 @@ diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index ede8792fa..ef0657446 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -13,7 +13,26 @@ def nav(context: Context) -> Dict: """ Render the navigation menu. """ + user = context['request'].user + nav_items = [] + + # Construct the navigation menu based upon the current user's permissions + for menu in MENUS: + groups = [] + for group in menu.groups: + items = [] + for item in group.items: + if user.has_perms(item.permissions): + buttons = [ + button for button in item.buttons if user.has_perms(button.permissions) + ] + items.append((item, buttons)) + if items: + groups.append((group, items)) + if groups: + nav_items.append((menu, groups)) + return { - "nav_items": MENUS, + "nav_items": nav_items, "request": context["request"] } From 3be9f6c4f3a74ab74f35f6cdc4de77593583c409 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 16:01:20 -0500 Subject: [PATCH 113/245] #8157 - Final work on L2VPN model --- netbox/dcim/api/serializers.py | 4 +- netbox/dcim/models/device_components.py | 7 +- netbox/ipam/api/nested_serializers.py | 8 +- netbox/ipam/api/serializers.py | 5 +- netbox/ipam/api/views.py | 2 +- netbox/ipam/filtersets.py | 51 ++++++++- netbox/ipam/forms/bulk_edit.py | 5 + netbox/ipam/graphql/schema.py | 6 ++ netbox/ipam/graphql/types.py | 16 +++ netbox/ipam/models/vlans.py | 7 +- netbox/ipam/tests/test_api.py | 38 +++---- netbox/ipam/tests/test_filtersets.py | 62 +++++------ netbox/ipam/tests/test_models.py | 81 ++++++++++++-- netbox/ipam/tests/test_views.py | 138 ++++++++++++++++++++++-- netbox/ipam/urls.py | 1 + netbox/ipam/views.py | 21 ++-- netbox/templates/dcim/interface.html | 4 + netbox/templates/ipam/vlan.html | 4 + 18 files changed, 376 insertions(+), 84 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8ac2aa738..32709000b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -10,6 +10,7 @@ from dcim.constants import * from dcim.models import * from ipam.api.nested_serializers import ( NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, + NestedL2VPNTerminationSerializer, ) from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField @@ -823,6 +824,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( @@ -841,7 +843,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', - 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'vrf', 'l2vpn_termination', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4d19a2d8d..70c21c165 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -649,10 +649,11 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo object_id_field='interface_id', related_query_name='+' ) - l2vpn = GenericRelation( + l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_query_name='interface', ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] @@ -828,6 +829,10 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo def link(self): return self.cable or self.wireless_link + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() + # # Pass-through ports diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 8316cb992..39305a017 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -11,6 +11,8 @@ __all__ = [ 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', + 'NestedL2VPNSerializer', + 'NestedL2VPNTerminationSerializer', 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', @@ -203,17 +205,17 @@ class NestedL2VPNSerializer(WritableNestedSerializer): class Meta: model = L2VPN fields = [ - 'id', 'url', 'display', 'name', 'type' + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type' ] class NestedL2VPNTerminationSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn_termination-detail') + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') l2vpn = NestedL2VPNSerializer() class Meta: model = L2VPNTermination fields = [ - 'id', 'url', 'display', 'l2vpn', 'assigned_object' + 'id', 'url', 'display', 'l2vpn' ] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a51043e27..36102f853 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -207,13 +207,14 @@ class VLANSerializer(NetBoxModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VLAN fields = [ - 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'prefix_count', + 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', + 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 36a6f02b6..f5a61c031 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -165,7 +165,7 @@ class L2VPNViewSet(NetBoxModelViewSet): class L2VPNTerminationViewSet(NetBoxModelViewSet): - queryset = L2VPNTermination.objects + queryset = L2VPNTermination.objects.prefetch_related('assigned_object') serializer_class = serializers.L2VPNTerminationSerializer filterset_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 03189a7cb..f682009ee 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -957,7 +957,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = L2VPN - fields = ['identifier', 'name', 'type', 'description'] + fields = ['id', 'identifier', 'name', 'type', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -977,13 +977,60 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): to_field_name='name', label='L2VPN (name)', ) + device = MultiValueCharFilter( + method='filter_device', + field_name='name', + label='Device (name)', + ) + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label='Device (ID)', + ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (name)', + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label='Interface (ID)', + ) + vlan = django_filters.ModelMultipleChoiceFilter( + field_name='vlan__name', + queryset=VLAN.objects.all(), + to_field_name='name', + label='VLAN (name)', + ) + vlan_vid = django_filters.NumberFilter( + field_name='vlan__vid', + label='VLAN number (1-4094)', + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + field_name='vlan', + queryset=VLAN.objects.all(), + label='VLAN (ID)', + ) class Meta: model = L2VPNTermination - fields = ['l2vpn'] + fields = ['id', ] def search(self, queryset, name, value): if not value.strip(): return queryset qs_filter = Q(l2vpn__name__icontains=value) return queryset.filter(qs_filter) + + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): + return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return queryset.filter( + interface__in=interface_ids + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index bbfa5bf9f..50fc51522 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -19,6 +19,7 @@ __all__ = ( 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', 'L2VPNBulkEditForm', + 'L2VPNTerminationBulkEditForm', 'PrefixBulkEditForm', 'RIRBulkEditForm', 'RoleBulkEditForm', @@ -458,3 +459,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): (None, ('tenant', 'description')), ) nullable_fields = ('tenant', 'description',) + + +class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): + model = L2VPN diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index f466c1857..5cd5e030e 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -17,6 +17,12 @@ class IPAMQuery(graphene.ObjectType): ip_range = ObjectField(IPRangeType) ip_range_list = ObjectListField(IPRangeType) + l2vpn = ObjectField(L2VPNType) + l2vpn_list = ObjectListField(L2VPNType) + + l2vpn_termination = ObjectField(L2VPNTerminationType) + l2vpn_termination_list = ObjectListField(L2VPNTerminationType) + prefix = ObjectField(PrefixType) prefix_list = ObjectListField(PrefixType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index ca206b4b8..5af2ca72a 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -11,6 +11,8 @@ __all__ = ( 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', + 'L2VPNType', + 'L2VPNTerminationType', 'PrefixType', 'RIRType', 'RoleType', @@ -151,3 +153,17 @@ class VRFType(NetBoxObjectType): model = models.VRF fields = '__all__' filterset_class = filtersets.VRFFilterSet + + +class L2VPNType(NetBoxObjectType): + class Meta: + model = models.L2VPN + fields = '__all__' + filtersets_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationType(NetBoxObjectType): + class Meta: + model = models.L2VPNTermination + fields = '__all__' + filtersets_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 3a7969405..f0e062721 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -174,10 +174,11 @@ class VLAN(NetBoxModel): blank=True ) - l2vpn = GenericRelation( + l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', + related_query_name='vlan' ) objects = VLANQuerySet.as_manager() @@ -234,3 +235,7 @@ class VLAN(NetBoxModel): Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk) ).distinct() + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 0e93bd43e..a5ebef2c7 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -947,28 +947,28 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): l2vpns = ( - L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD ) L2VPN.objects.bulk_create(l2vpns) class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): model = L2VPNTermination - brief_fields = ['display', 'id', 'l2vpn', 'assigned_object', 'assigned_object_id', 'assigned_object_type', 'url'] + brief_fields = ['display', 'id', 'l2vpn', 'url'] @classmethod def setUpTestData(cls): vlans = ( - VLAN(name='VLAN 1', vid=650001), - VLAN(name='VLAN 2', vid=650002), - VLAN(name='VLAN 3', vid=650003), - VLAN(name='VLAN 4', vid=650004), - VLAN(name='VLAN 5', vid=650005), - VLAN(name='VLAN 6', vid=650006), - VLAN(name='VLAN 7', vid=650007) + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) ) VLAN.objects.bulk_create(vlans) @@ -986,24 +986,26 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + cls.create_data = [ { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[3], + 'assigned_object_id': vlans[3].pk, }, { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[4], + 'assigned_object_id': vlans[4].pk, }, { - 'l2vpn': l2vpns[0], + 'l2vpn': l2vpns[0].pk, 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[5], + 'assigned_object_id': vlans[5].pk, }, ] cls.bulk_update_data = { - 'l2vpn': l2vpns[2] + 'l2vpn': l2vpns[2].pk } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c5cffc7dc..2b5fb0759 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1465,8 +1465,7 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): - # TODO: L2VPN Tests +class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPN.objects.all() filterset = L2VPNFilterSet @@ -1480,20 +1479,8 @@ class L2VPNTest(TestCase, ChangeLoggedFilterSetTests): ) L2VPN.objects.bulk_create(l2vpns) - def test_created(self): - from datetime import date, date - pk_list = self.queryset.values_list('pk', flat=True)[:2] - print(pk_list) - self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'created': '2021-01-01T00:00:00'} - fs = self.filterset({}, self.queryset).qs.all() - for res in fs: - print(f'{res.name}:{res.created}') - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - -class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): - # TODO: L2VPN Termination Tests +class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPNTermination.objects.all() filterset = L2VPNTerminationFilterSet @@ -1511,22 +1498,24 @@ class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): device_role=device_role, status='active' ) - interfaces = Interface.objects.bulk_create( - Interface(name='GigabitEthernet1/0/1', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/2', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/3', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/4', device=device, type='1000baset'), - Interface(name='GigabitEthernet1/0/5', device=device, type='1000baset'), + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + Interface(name='Interface 6', device=device, type='1000baset') ) + Interface.objects.bulk_create(interfaces) + vlans = ( - VLAN(name='VLAN 1', vid=650001), - VLAN(name='VLAN 2', vid=650002), - VLAN(name='VLAN 3', vid=650003), - VLAN(name='VLAN 4', vid=650004), - VLAN(name='VLAN 5', vid=650005), - VLAN(name='VLAN 6', vid=650006), - VLAN(name='VLAN 7', vid=650007) + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655) ) VLAN.objects.bulk_create(vlans) @@ -1534,26 +1523,33 @@ class L2VPNTerminationTest(TestCase, ChangeLoggedFilterSetTests): l2vpns = ( L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 3', type='vpls'), # No RD, ) L2VPN.objects.bulk_create(l2vpns) l2vpnterminations = ( L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]), ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + def test_l2vpns(self): l2vpns = L2VPN.objects.all()[:2] params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_interfaces(self): interfaces = Interface.objects.all()[:2] params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + qs = self.filterset(params, self.queryset).qs + results = qs.all() self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'interface': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index ce4643516..1b5fbadc3 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -2,8 +2,9 @@ from netaddr import IPNetwork, IPSet from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination class TestAggregate(TestCase): @@ -540,11 +541,75 @@ class TestVLANGroup(TestCase): self.assertEqual(vlangroup.get_next_available_vid(), 105) -class TestL2VPN(TestCase): - # TODO: L2VPN Tests - pass - - class TestL2VPNTermination(TestCase): - # TODO: L2VPN Termination Tests - pass + + @classmethod + def setUpTestData(cls): + + 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) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + ) + + Interface.objects.bulk_create(interfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + def test_duplicate_interface_terminations(self): + device = Device.objects.first() + interface = Interface.objects.filter(device=device).first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface) + + self.assertRaises(ValidationError, duplicate.clean) + + def test_duplicate_vlan_terminations(self): + vlan = Interface.objects.first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) + self.assertRaises(ValidationError, duplicate.clean) + diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8d1b9bd1b..dd3733d4d 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,14 +1,18 @@ import datetime +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface +from extras.choices import ObjectChangeActionChoices +from extras.models import ObjectChange from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from utilities.testing import ViewTestCases, create_tags +from users.models import ObjectPermission +from utilities.testing import ViewTestCases, create_tags, post_data class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -749,10 +753,130 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): - # TODO: L2VPN Tests - pass + model = L2VPN + csv_data = ( + 'name,slug,type,identifier', + 'L2VPN 5,l2vpn-5,vxlan,456', + 'L2VPN 6,l2vpn-6,vxlan,444', + ) + bulk_edit_data = { + 'description': 'New Description', + } + + @classmethod + def setUpTestData(cls): + rts = ( + RouteTarget(name='64534:123'), + RouteTarget(name='64534:321') + ) + RouteTarget.objects.bulk_create(rts) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier='650001'), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vxlan', identifier='650002'), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vxlan', identifier='650003') + ) + + L2VPN.objects.bulk_create(l2vpns) + + cls.form_data = { + 'name': 'L2VPN 8', + 'slug': 'l2vpn-8', + 'type': 'vxlan', + 'identifier': 123, + 'description': 'Description', + 'import_targets': [rts[0].pk], + 'export_targets': [rts[1].pk] + } + + print(cls.form_data) -class L2VPNTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): - # TODO: L2VPN Termination Tests - pass +class L2VPNTerminationTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + + model = L2VPNTermination + + @classmethod + def setUpTestData(cls): + 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) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + + interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') + l2vpn = L2VPN.objects.create(name='L2VPN 1', type='vxlan', identifier=650001) + l2vpn_vlans = L2VPN.objects.create(name='L2VPN 2', type='vxlan', identifier=650002) + + vlans = ( + VLAN(name='Vlan 1', vid=1001), + VLAN(name='Vlan 2', vid=1002), + VLAN(name='Vlan 3', vid=1003), + VLAN(name='Vlan 4', vid=1004), + VLAN(name='Vlan 5', vid=1005), + VLAN(name='Vlan 6', vid=1006) + ) + VLAN.objects.bulk_create(vlans) + + terminations = ( + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2]) + ) + L2VPNTermination.objects.bulk_create(terminations) + + cls.form_data = { + 'l2vpn': l2vpn.pk, + 'device': device.pk, + 'interface': interface.pk, + } + + cls.csv_data = ( + "l2vpn,vlan", + "L2VPN 2,Vlan 4", + "L2VPN 2,Vlan 5", + "L2VPN 2,Vlan 6", + ) + + cls.bulk_edit_data = {} + + # + # Custom assertions + # + + def assertInstanceEqual(self, instance, data, exclude=None, api=False): + """ + Override parent + """ + if exclude is None: + exclude = [] + + fields = [k for k in data.keys() if k not in exclude] + model_dict = self.model_to_dict(instance, fields=fields, api=api) + + # Omit any dictionary keys which are not instance attributes or have been excluded + relevant_data = { + k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude + } + + # Handle relations on the model + for k, v in model_dict.items(): + if isinstance(v, object) and hasattr(v, 'first'): + model_dict[k] = v.first().pk + + self.assertDictEqual(model_dict, relevant_data) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 65a6b55ad..e00b0365f 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -201,6 +201,7 @@ urlpatterns = [ path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-termination/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 77539434c..35103be48 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1141,6 +1141,13 @@ class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable + form = forms.ServiceBulkEditForm + + +class ServiceBulkDeleteView(generic.BulkDeleteView): + queryset = Service.objects.prefetch_related('device', 'virtual_machine') + filterset = filtersets.ServiceFilterSet + table = tables.ServiceTable # L2VPN @@ -1232,14 +1239,14 @@ class L2VPNTerminationBulkImportView(generic.BulkImportView): table = tables.L2VPNTerminationTable +class L2VPNTerminationBulkEditView(generic.BulkEditView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable + form = forms.L2VPNTerminationBulkEditForm + + class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): queryset = L2VPNTermination.objects.all() filterset = filtersets.L2VPNTerminationFilterSet table = tables.L2VPNTerminationTable - form = forms.ServiceBulkEditForm - - -class ServiceBulkDeleteView(generic.BulkDeleteView): - queryset = Service.objects.prefetch_related('device', 'virtual_machine') - filterset = filtersets.ServiceFilterSet - table = tables.ServiceTable diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index e98750518..247592e14 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -104,6 +104,10 @@ LAG {{ object.lag|linkify|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} + diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index fd0ba36a3..53bb75b8f 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -64,6 +64,10 @@ Description {{ object.description|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} + From 6e983d154264b6af6586db01ef93586a7276261f Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 16:14:30 -0500 Subject: [PATCH 114/245] Fix up some PEP errors --- netbox/ipam/api/nested_serializers.py | 1 - netbox/ipam/choices.py | 31 +++++++++++++-------------- netbox/ipam/tests/test_models.py | 1 - 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 39305a017..07a7c9598 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -218,4 +218,3 @@ class NestedL2VPNTerminationSerializer(WritableNestedSerializer): fields = [ 'id', 'url', 'display', 'l2vpn' ] - diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index a867b05bc..72cd4ff73 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -192,26 +192,25 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_VPLS, 'VPLS'), )), ('E-Line', ( - (TYPE_EPL, 'EPL'), - (TYPE_EVPL, 'EVPL'), - )), + (TYPE_EPL, 'EPL'), + (TYPE_EVPL, 'EVPL'), + )), ('E-LAN', ( - (TYPE_EPLAN, 'Ethernet Private LAN'), - (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), - )), + (TYPE_EPLAN, 'Ethernet Private LAN'), + (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), + )), ('E-Tree', ( - (TYPE_EPTREE, 'Ethernet Private Tree'), - (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), - )), + (TYPE_EPTREE, 'Ethernet Private Tree'), + (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), + )), ('VXLAN', ( - (TYPE_VXLAN, 'VXLAN'), - (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), - )), + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), ('L2VPN E-VPN', ( - (TYPE_MPLS_EVPN, 'MPLS EVPN'), - (TYPE_PBB_EVPN, 'PBB EVPN'), - )) - + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )) ) P2P = ( diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 1b5fbadc3..3bd7e8ccb 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -612,4 +612,3 @@ class TestL2VPNTermination(TestCase): L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) self.assertRaises(ValidationError, duplicate.clean) - From dd6bfed565fc25f841f58bc3f70e339da37f69ba Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 29 Jun 2022 18:37:31 -0500 Subject: [PATCH 115/245] Add migration file --- .../0059_l2vpn_l2vpntermination_and_more.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py diff --git a/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py b/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py new file mode 100644 index 000000000..a8e5ace25 --- /dev/null +++ b/netbox/ipam/migrations/0059_l2vpn_l2vpntermination_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 4.0.5 on 2022-06-28 04:57 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0007_contact_link'), + ('extras', '0076_configcontext_locations'), + ('ipam', '0058_ipaddress_nat_inside_nonunique'), + ] + + operations = [ + migrations.CreateModel( + name='L2VPN', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField()), + ('type', models.CharField(max_length=50)), + ('identifier', models.BigIntegerField(blank=True, null=True, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')), + ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='l2vpns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'L2VPN', + 'ordering': ('identifier', 'name'), + }, + ), + migrations.CreateModel( + name='L2VPNTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('assigned_object_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('assigned_object_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'L2VPN Termination', + 'ordering': ('l2vpn',), + }, + ), + migrations.AddConstraint( + model_name='l2vpntermination', + constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_assigned_object'), + ), + ] From 5b397a98272c15cc2dc582abb10d629e8f753429 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 30 Jun 2022 08:29:08 -0500 Subject: [PATCH 116/245] Add docs --- docs/core-functionality/ipam.md | 5 +++++ docs/development/models.md | 2 ++ docs/models/ipam/l2vpn.md | 19 +++++++++++++++++++ docs/models/ipam/l2vpntermination.md | 12 ++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 docs/models/ipam/l2vpn.md create mode 100644 docs/models/ipam/l2vpntermination.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 01bb3c76d..c86819380 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -26,3 +26,8 @@ --- {!models/ipam/asn.md!} + +--- + +{!models/ipam/l2vpn.md!} +{!models/ipam/l2vpntermination.md!} diff --git a/docs/development/models.md b/docs/development/models.md index ae1bab7e7..b6b2e4da2 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) * [ipam.IPAddress](../models/ipam/ipaddress.md) * [ipam.IPRange](../models/ipam/iprange.md) +* [ipam.L2VPN](../models/ipam/l2vpn.md) +* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md) * [ipam.Prefix](../models/ipam/prefix.md) * [ipam.RouteTarget](../models/ipam/routetarget.md) * [ipam.Service](../models/ipam/service.md) diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md new file mode 100644 index 000000000..9c50a6407 --- /dev/null +++ b/docs/models/ipam/l2vpn.md @@ -0,0 +1,19 @@ +# L2VPN + +A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS or EPL. Each L2VPN can be identified by name as well as an optional unique identifier (VNI would be an example). + +Each L2VPN instance must have one of the following type associated with it: + +* VPLS +* VPWS +* EPL +* EVPL +* EP-LAN +* EVP-LAN +* EP-TREE +* EVP-TREE +* VXLAN +* VXLAN EVPN +* MPLS-EVPN +* PBB-EVPN + diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md new file mode 100644 index 000000000..9135f72a3 --- /dev/null +++ b/docs/models/ipam/l2vpntermination.md @@ -0,0 +1,12 @@ +# L2VPN Termination + +A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPN's may only have 2 termination points (point-to-point) while others may have many terminations (multipoint). + +Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN. + +The following types of L2VPN's are considered point-to-point: + +* VPWS +* EPL +* EP-LAN +* EP-TREE \ No newline at end of file From 65f4895dd652e31e1e9f432dc549b669e7cdb339 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Jun 2022 14:35:41 -0400 Subject: [PATCH 117/245] Add ActionsMixin to __all__ --- netbox/netbox/views/generic/mixins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 4b3fa0740..8e363f0a5 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -3,6 +3,7 @@ from collections import defaultdict from utilities.permissions import get_permission_for_model __all__ = ( + 'ActionsMixin', 'TableMixin', ) From 3a6f46bf38e2a6f8fd8076620c8199311e0c6950 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Jun 2022 15:15:07 -0400 Subject: [PATCH 118/245] Closes #9075: Introduce AbortRequest exception for cleanly interrupting object mutations --- docs/plugins/development/exceptions.md | 28 +++++++++++++ docs/release-notes/version-3.3.md | 1 + mkdocs.yml | 1 + netbox/netbox/api/viewsets/__init__.py | 9 ++++ netbox/netbox/views/generic/bulk_views.py | 46 +++++++++++---------- netbox/netbox/views/generic/object_views.py | 29 +++++++------ netbox/utilities/exceptions.py | 17 +++++++- 7 files changed, 95 insertions(+), 36 deletions(-) create mode 100644 docs/plugins/development/exceptions.md diff --git a/docs/plugins/development/exceptions.md b/docs/plugins/development/exceptions.md new file mode 100644 index 000000000..80f5db258 --- /dev/null +++ b/docs/plugins/development/exceptions.md @@ -0,0 +1,28 @@ +# Exceptions + +The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios. + +## `AbortRequest` + +NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer. + +For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model: + +```python +from django.db.models.signals import pre_save +from django.dispatch import receiver +from dcim.models import Site +from utilities.exceptions import AbortRequest + +PROHIBITED_NAMES = ('foo', 'bar', 'baz') + +@receiver(pre_save, sender=Site) +def test_abort_request(instance, **kwargs): + if instance.name.lower() in PROHIBITED_NAMES: + raise AbortRequest(f"Site name can't be {instance.name}!") +``` + +An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy. + +!!! tip "Consider custom validation rules" + This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 62644f55f..a0abb81c4 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -35,6 +35,7 @@ ### Plugins API +* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations * [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes diff --git a/mkdocs.yml b/mkdocs.yml index 507b25627..88a2794e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -118,6 +118,7 @@ nav: - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' + - Exceptions: 'plugins/development/exceptions.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 462c07c6f..2d3780bde 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet from extras.models import ExportTemplate from netbox.api.exceptions import SerializerNotFound from utilities.api import get_serializer_for_model +from utilities.exceptions import AbortRequest from .mixins import * __all__ = ( @@ -125,6 +126,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali *args, **kwargs ) + except AbortRequest as e: + logger.debug(e.message) + return self.finalize_response( + request, + Response({'detail': e.message}, status=400), + *args, + **kwargs + ) def list(self, request, *args, **kwargs): """ diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index bb1c2b8e3..82244bcd2 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -1,6 +1,5 @@ import logging import re -from collections import defaultdict from copy import deepcopy from django.contrib import messages @@ -12,11 +11,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django_tables2.export import TableExport +from django.utils.safestring import mark_safe from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import PermissionsViolation +from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, ) @@ -264,10 +264,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: pass - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) + clear_webhooks.send(sender=self) else: logger.debug("Form validation failed") @@ -392,10 +392,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): except ValidationError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object import failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -542,10 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): messages.error(self.request, ", ".join(e.messages)) clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -639,10 +637,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): messages.success(request, f"Renamed {len(selected_objects)} {model_name}") return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -717,11 +714,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): if hasattr(obj, 'snapshot'): obj.snapshot() obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete objects") handle_protectederror(queryset, request, e) return redirect(self.get_return_url(request)) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(self.get_return_url(request)) + msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}" logger.info(msg) messages.success(request, msg) @@ -829,10 +832,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not form.errors: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 82867b429..dc078a7e2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortTransaction, PermissionsViolation +from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model @@ -246,10 +246,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): except AbortTransaction: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not model_form.errors: @@ -410,10 +409,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): return redirect(return_url) - except PermissionsViolation: - msg = "Object save failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -489,11 +487,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): try: obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete object") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(obj.get_absolute_url()) + msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj) logger.info(msg) messages.success(request, msg) @@ -603,10 +607,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): else: return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) return render(request, self.template_name, { diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 4ba62bc01..657e90745 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -1,6 +1,13 @@ from rest_framework import status from rest_framework.exceptions import APIException +__all__ = ( + 'AbortRequest', + 'AbortTransaction', + 'PermissionsViolation', + 'RQWorkerNotRunningException', +) + class AbortTransaction(Exception): """ @@ -9,12 +16,20 @@ class AbortTransaction(Exception): pass +class AbortRequest(Exception): + """ + Raised to cleanly abort a request (for example, by a pre_save signal receiver). + """ + def __init__(self, message): + self.message = message + + class PermissionsViolation(Exception): """ Raised when an operation was prevented because it would violate the allowed permissions. """ - pass + message = "Operation failed due to object-level permissions violation" class RQWorkerNotRunningException(APIException): From be778353b7b97b779261f7c06aa9a3d823ff49e4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 30 Jun 2022 17:13:26 -0400 Subject: [PATCH 119/245] #1099: Restore PoE fields on interface edit form --- netbox/templates/dcim/interface_edit.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index ddda1ae31..d6fdfd0e1 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -72,6 +72,14 @@ {% endif %} +
    +
    +
    Power over Ethernet (PoE)
    +
    + {% render_field form.poe_mode %} + {% render_field form.poe_type %} +
    +
    802.1Q Switching
    From c6dfdf10e5938e8ee7af18e31663023fb4b8390d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 11:38:39 -0400 Subject: [PATCH 120/245] Introduce qs_filter_from_constraints() for constructing object permission QS filters --- netbox/netbox/authentication.py | 22 +++++++++------------- netbox/utilities/permissions.py | 24 ++++++++++++++++++++++++ netbox/utilities/querysets.py | 27 +++++++-------------------- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 00fb3ee66..c16095fdc 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -9,7 +9,9 @@ from django.core.exceptions import ImproperlyConfigured from django.db.models import Q from users.models import ObjectPermission -from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct +from utilities.permissions import ( + permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, +) UserModel = get_user_model() @@ -99,8 +101,10 @@ class ObjectPermissionMixin: if not user_obj.is_active or user_obj.is_anonymous: return False + object_permissions = self.get_all_permissions(user_obj) + # If no applicable ObjectPermissions have been created for this user/permission, deny permission - if perm not in self.get_all_permissions(user_obj): + if perm not in object_permissions: return False # If no object has been specified, grant permission. (The presence of a permission in this set tells @@ -113,21 +117,13 @@ class ObjectPermissionMixin: if model._meta.label_lower != '.'.join((app_label, model_name)): raise ValueError(f"Invalid permission {perm} for model {model}") - # Compile a query filter that matches all instances of the specified model - obj_perm_constraints = self.get_all_permissions(user_obj)[perm] - constraints = Q() - for perm_constraints in obj_perm_constraints: - if perm_constraints: - constraints |= Q(**perm_constraints) - else: - # Found ObjectPermission with null constraints; allow model-level access - constraints = Q() - break + # Compile a QuerySet filter that matches all instances of the specified model + qs_filter = qs_filter_from_constraints(object_permissions[perm]) # Permission to perform the requested action on the object depends on whether the specified object matches # the specified constraints. Note that this check is made against the *database* record representing the object, # not the instance itself. - return model.objects.filter(constraints, pk=obj.pk).exists() + return model.objects.filter(qs_filter, pk=obj.pk).exists() class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend): diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index b11bf504a..123df9e45 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -1,5 +1,14 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +__all__ = ( + 'get_permission_for_model', + 'permission_is_exempt', + 'qs_filter_from_constraints', + 'resolve_permission', + 'resolve_permission_ct', +) def get_permission_for_model(model, action): @@ -69,3 +78,18 @@ def permission_is_exempt(name): return True return False + + +def qs_filter_from_constraints(constraints): + """ + Construct a Q filter object from an iterable of ObjectPermission constraints. + """ + params = Q() + for constraint in constraints: + if constraint: + params |= Q(**constraint) + else: + # Found null constraint; permit model-level access + return Q() + + return params diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 97d2e8779..8ec6012bf 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,6 +1,6 @@ -from django.db.models import Q, QuerySet +from django.db.models import QuerySet -from utilities.permissions import permission_is_exempt +from utilities.permissions import permission_is_exempt, qs_filter_from_constraints class RestrictedQuerySet(QuerySet): @@ -28,23 +28,10 @@ class RestrictedQuerySet(QuerySet): # Filter the queryset to include only objects with allowed attributes else: - attrs = Q() - for perm_attrs in user._object_perm_cache[permission_required]: - if type(perm_attrs) is list: - for p in perm_attrs: - attrs |= Q(**p) - elif perm_attrs: - attrs |= Q(**perm_attrs) - else: - # Any permission with null constraints grants access to _all_ instances - attrs = Q() - break - else: - # for else, when no break - # avoid duplicates when JOIN on many-to-many fields without using DISTINCT. - # DISTINCT acts globally on the entire request, which may not be desirable. - allowed_objects = self.model.objects.filter(attrs) - attrs = Q(pk__in=allowed_objects) - qs = self.filter(attrs) + attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required]) + # #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT. + # DISTINCT acts globally on the entire request, which may not be desirable. + allowed_objects = self.model.objects.filter(attrs) + qs = self.filter(pk__in=allowed_objects) return qs From 12c138b341faff6d2413c620488e124cbbacf8ec Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 13:34:10 -0400 Subject: [PATCH 121/245] Closes #9074: Enable referencing the current user when evaluating permission constraints --- docs/administration/permissions.md | 2 +- docs/models/users/objectpermission.md | 14 ++++++++++++++ docs/release-notes/version-3.3.md | 2 ++ netbox/netbox/authentication.py | 6 +++++- netbox/users/admin/forms.py | 9 ++++++--- netbox/users/constants.py | 2 ++ netbox/utilities/permissions.py | 15 +++++++++++++-- netbox/utilities/querysets.py | 6 +++++- 8 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index f859266af..60717c28a 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -4,7 +4,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replaces {!models/users/objectpermission.md!} -### Example Constraint Definitions +#### Example Constraint Definitions | Constraints | Description | | ----------- | ----------- | diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md index 48970dd05..075a2cae5 100644 --- a/docs/models/users/objectpermission.md +++ b/docs/models/users/objectpermission.md @@ -53,3 +53,17 @@ To achieve a logical OR with a different set of constraints, define multiple obj ``` Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. + +### Tokens + +!!! info "This feature was introduced in NetBox v3.3" + +When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as: + +```json +{ + "created_by": "$user" +} +``` + +The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index a0abb81c4..ea9e67a38 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -15,6 +15,8 @@ #### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) +#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index c16095fdc..62512943e 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -8,6 +8,7 @@ from django.contrib.auth.models import Group, AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.db.models import Q +from users.constants import CONSTRAINT_TOKEN_USER from users.models import ObjectPermission from utilities.permissions import ( permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, @@ -118,7 +119,10 @@ class ObjectPermissionMixin: raise ValueError(f"Invalid permission {perm} for model {model}") # Compile a QuerySet filter that matches all instances of the specified model - qs_filter = qs_filter_from_constraints(object_permissions[perm]) + tokens = { + CONSTRAINT_TOKEN_USER: user_obj, + } + qs_filter = qs_filter_from_constraints(object_permissions[perm], tokens) # Permission to perform the requested action on the object depends on whether the specified object matches # the specified constraints. Note that this check is made against the *database* record representing the object, diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index bc3d44862..540735ecc 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -3,11 +3,11 @@ from django.contrib.auth.models import Group, User from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError -from django.db.models import Q -from users.constants import OBJECTPERMISSION_OBJECT_TYPES +from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES from users.models import ObjectPermission, Token from utilities.forms.fields import ContentTypeMultipleChoiceField +from utilities.permissions import qs_filter_from_constraints __all__ = ( 'GroupAdminForm', @@ -125,7 +125,10 @@ class ObjectPermissionForm(forms.ModelForm): for ct in object_types: model = ct.model_class() try: - model.objects.filter(*[Q(**c) for c in constraints]).exists() + tokens = { + CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID + } + model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() except FieldError as e: raise ValidationError({ 'constraints': f'Invalid filter for {model}: {e}' diff --git a/netbox/users/constants.py b/netbox/users/constants.py index e6917c482..1e6e7c71c 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -6,3 +6,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( Q(app_label='auth', model__in=['group', 'user']) | Q(app_label='users', model__in=['objectpermission', 'token']) ) + +CONSTRAINT_TOKEN_USER = '$user' diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 123df9e45..b20aafce0 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -80,14 +80,25 @@ def permission_is_exempt(name): return False -def qs_filter_from_constraints(constraints): +def qs_filter_from_constraints(constraints, tokens=None): """ Construct a Q filter object from an iterable of ObjectPermission constraints. + + Args: + tokens: A dictionary mapping string tokens to be replaced with a value. """ + if tokens is None: + tokens = {} + + def _replace_tokens(value, tokens): + if type(value) is list: + return list(map(lambda v: tokens.get(v, v), value)) + return tokens.get(value, value) + params = Q() for constraint in constraints: if constraint: - params |= Q(**constraint) + params |= Q(**{k: _replace_tokens(v, tokens) for k, v in constraint.items()}) else: # Found null constraint; permit model-level access return Q() diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 8ec6012bf..955a10d64 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,5 +1,6 @@ from django.db.models import QuerySet +from users.constants import CONSTRAINT_TOKEN_USER from utilities.permissions import permission_is_exempt, qs_filter_from_constraints @@ -28,7 +29,10 @@ class RestrictedQuerySet(QuerySet): # Filter the queryset to include only objects with allowed attributes else: - attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required]) + tokens = { + CONSTRAINT_TOKEN_USER: user, + } + attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required], tokens) # #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT. # DISTINCT acts globally on the entire request, which may not be desirable. allowed_objects = self.model.objects.filter(attrs) From c11af40a061ac5346d7d44579dd2decad4826001 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 13:52:37 -0400 Subject: [PATCH 122/245] prepare_cloned_fields() should always return a QueryDict --- netbox/utilities/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 731b67e43..51c411004 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -286,7 +286,7 @@ def prepare_cloned_fields(instance): """ # Generate the clone attributes from the instance if not hasattr(instance, 'clone'): - return None + return QueryDict() attrs = instance.clone() # Prepare querydict parameters From a57398b0d678405d68f785f08d0e2a393eb21768 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 14:45:22 -0400 Subject: [PATCH 123/245] Closes #9647: Introduce customfield_value template tag --- docs/plugins/development/templates.md | 2 ++ docs/release-notes/version-3.3.md | 1 + .../templates/inc/panels/custom_fields.html | 28 +------------------ .../templates/builtins/customfield_value.html | 27 ++++++++++++++++++ .../utilities/templatetags/builtins/tags.py | 15 ++++++++++ 5 files changed, 46 insertions(+), 27 deletions(-) create mode 100644 netbox/utilities/templates/builtins/customfield_value.html diff --git a/docs/plugins/development/templates.md b/docs/plugins/development/templates.md index 64616c442..20838149f 100644 --- a/docs/plugins/development/templates.md +++ b/docs/plugins/development/templates.md @@ -215,6 +215,8 @@ The following custom template tags are available in NetBox. ::: utilities.templatetags.builtins.tags.checkmark +::: utilities.templatetags.builtins.tags.customfield_value + ::: utilities.templatetags.builtins.tags.tag ## Filters diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ea9e67a38..f5cb8eee1 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -40,6 +40,7 @@ * [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations * [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes +* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag ### Other Changes diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index b18d44030..90059447f 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -16,33 +16,7 @@ {{ field }} - {% if field.type == 'integer' and value is not None %} - {{ value }} - {% elif field.type == 'longtext' and value %} - {{ value|markdown }} - {% elif field.type == 'boolean' and value == True %} - {% checkmark value true="True" %} - {% elif field.type == 'boolean' and value == False %} - {% checkmark value false="False" %} - {% elif field.type == 'url' and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 'json' and value %} -
    {{ value|json }}
    - {% elif field.type == 'multiselect' and value %} - {{ value|join:", " }} - {% elif field.type == 'object' and value %} - {{ value|linkify }} - {% elif field.type == 'multiobject' and value %} - {% for obj in value %} - {{ obj|linkify }}{% if not forloop.last %}
    {% endif %} - {% endfor %} - {% elif value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - {{ ''|placeholder }} - {% endif %} + {% customfield_value field value %} {% endfor %} diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html new file mode 100644 index 000000000..8fedb03d5 --- /dev/null +++ b/netbox/utilities/templates/builtins/customfield_value.html @@ -0,0 +1,27 @@ +{% if field.type == 'integer' and value is not None %} + {{ value }} +{% elif field.type == 'longtext' and value %} + {{ value|markdown }} +{% elif field.type == 'boolean' and value == True %} + {% checkmark value true="True" %} +{% elif field.type == 'boolean' and value == False %} + {% checkmark value false="False" %} +{% elif field.type == 'url' and value %} + {{ value|truncatechars:70 }} +{% elif field.type == 'json' and value %} +
    {{ value|json }}
    +{% elif field.type == 'multiselect' and value %} + {{ value|join:", " }} +{% elif field.type == 'object' and value %} + {{ value|linkify }} +{% elif field.type == 'multiobject' and value %} + {% for object in value %} + {{ object|linkify }}{% if not forloop.last %}
    {% endif %} + {% endfor %} +{% elif value %} + {{ value }} +{% elif field.required %} + Not defined +{% else %} + {{ ''|placeholder }} +{% endif %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 666b6a31c..ed464b332 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -18,6 +18,21 @@ def tag(value, viewname=None): } +@register.inclusion_tag('builtins/customfield_value.html') +def customfield_value(customfield, value): + """ + Render a custom field value according to the field type. + + Args: + customfield: A CustomField instance + value: The custom field value applied to an object + """ + return { + 'customfield': customfield, + 'value': value, + } + + @register.inclusion_tag('builtins/badge.html') def badge(value, bg_color=None, show_empty=False): """ From a5124ab9c835896ff434812bf870d1add82a3afe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 15:10:31 -0400 Subject: [PATCH 124/245] Closes #8511: Enable custom fields and tags for circuit terminations --- docs/release-notes/version-3.3.md | 3 + netbox/circuits/api/serializers.py | 4 +- netbox/circuits/filtersets.py | 2 +- netbox/circuits/forms/models.py | 4 +- netbox/circuits/graphql/types.py | 3 +- ...7_circuittermination_tags_custom_fields.py | 24 +++ netbox/circuits/models/circuits.py | 13 +- netbox/templates/circuits/circuit.html | 140 +++++++++--------- .../circuits/circuittermination_edit.html | 8 + .../circuits/inc/circuit_termination.html | 33 ++++- 10 files changed, 155 insertions(+), 79 deletions(-) create mode 100644 netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f5cb8eee1..d158e6cf9 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -28,6 +28,7 @@ * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping +* [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results * [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields @@ -51,6 +52,8 @@ * circuits.Circuit * Added optional `termination_date` field +* circuits.CircuitTermination + * Added 'custom_fields' and 'tags' fields * dcim.Device * The `position` field has been changed from an integer to a decimal * dcim.DeviceType diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 2bb3cd266..844cfce89 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -98,7 +98,7 @@ class CircuitSerializer(NetBoxModelSerializer): ] -class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer): +class CircuitTerminationSerializer(NetBoxModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False, allow_null=True) @@ -110,5 +110,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - '_occupied', 'created', 'last_updated', + '_occupied', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 67a0d1b02..a74ff5c5a 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte ).distinct() -class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet): +class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 907c39586..7bd7abbbf 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -116,7 +116,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm): } -class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): +class CircuitTerminationForm(NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False, @@ -161,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): model = CircuitTermination fields = [ 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', - 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags', ] help_texts = { 'port_speed': "Physical circuit speed", diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 027b53203..094b78d07 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,5 @@ from circuits import filtersets, models +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( @@ -10,7 +11,7 @@ __all__ = ( ) -class CircuitTerminationType(ObjectType): +class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): class Meta: model = models.CircuitTermination diff --git a/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py b/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py new file mode 100644 index 000000000..c87bc4219 --- /dev/null +++ b/netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py @@ -0,0 +1,24 @@ +import django.core.serializers.json +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0076_configcontext_locations'), + ('circuits', '0036_circuit_termination_date'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='circuittermination', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 5df6f1b85..cf6ffc503 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,7 +5,9 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel +from netbox.models import ( + ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, +) from netbox.models.features import WebhooksMixin __all__ = ( @@ -141,7 +143,14 @@ class Circuit(NetBoxModel): return CircuitStatusChoices.colors.get(self.status) -class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): +class CircuitTermination( + CustomFieldsMixin, + CustomLinksMixin, + TagsMixin, + WebhooksMixin, + ChangeLoggedModel, + LinkTermination +): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index a4c41f871..a11139032 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -8,74 +8,78 @@ {% endblock %} {% block content %} -
    -
    -
    -
    - Circuit -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Provider{{ object.provider|linkify }}
    Circuit ID{{ object.cid }}
    Type{{ object.type|linkify }}
    Status{% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
    Install Date{{ object.install_date|annotated_date|placeholder }}
    Termination Date{{ object.termination_date|annotated_date|placeholder }}
    Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
    Description{{ object.description|placeholder }}
    -
    +
    +
    +
    +
    Circuit
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Provider{{ object.provider|linkify }}
    Circuit ID{{ object.cid }}
    Type{{ object.type|linkify }}
    Status{% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
    Install Date{{ object.install_date|annotated_date|placeholder }}
    Termination Date{{ object.termination_date|annotated_date|placeholder }}
    Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
    Description{{ object.description|placeholder }}
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
    -
    - {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} - {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/panels/contacts.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
    -
    -
    -
    - {% plugin_full_width_page object %} +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    +
    + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} +
    +
    + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    {% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index f8393f945..606e12b5e 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -10,6 +10,7 @@ {% render_field form.provider %} {% render_field form.circuit %} {% render_field form.term_side %} + {% render_field form.tags %} {% render_field form.mark_connected %} {% with providernetwork_tab_active=form.initial.provider_network %}
    @@ -47,6 +48,13 @@ {% render_field form.pp_info %} {% render_field form.description %}
    + +
    +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    {% endblock %} {# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index b673cd4a3..f6bb377ec 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -2,7 +2,6 @@
    - Termination - {{ side }} Side
    {% if not termination and perms.circuits.add_circuittermination %} @@ -10,10 +9,10 @@ {% endif %} {% if termination and perms.circuits.change_circuittermination %} - + Edit - + Swap {% endif %} @@ -23,6 +22,7 @@ {% endif %}
    +
    Termination {{ side }}
    {% if termination %} @@ -110,6 +110,33 @@ Description {{ termination.description|placeholder }} + + Tags + + {% for tag in termination.tags.all %} + {% tag tag %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} + + + {% for group_name, fields in termination.get_custom_fields_by_group.items %} + + + {{ group_name|default:"Custom Fields" }} + + + {% for field, value in fields.items %} + + + {{ field }} + + + {% customfield_value field value %} + + + {% endfor %} + {% endfor %} {% else %} None From 23f391c5b59d5e01321cf5b83e5337c45f9a09ac Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 15:52:16 -0400 Subject: [PATCH 125/245] Closes #9228: Add serialize_object() method to ChangeLoggingMixin --- docs/release-notes/version-3.3.md | 1 + netbox/extras/webhooks.py | 13 ++++++++++--- netbox/netbox/models/features.py | 14 +++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index d158e6cf9..0e3cfd35f 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -40,6 +40,7 @@ * [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations * [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view +* [#9228](https://github.com/netbox-community/netbox/issues/9228) - Subclasses of `ChangeLoggingMixin` can override `serialize_object()` to control JSON serialization for change logging * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes * [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index d8739cb55..334539026 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,6 +1,5 @@ import hashlib import hmac -from collections import defaultdict from django.contrib.contenttypes.models import ContentType from django.utils import timezone @@ -27,10 +26,18 @@ def serialize_for_webhook(instance): def get_snapshots(instance, action): - return { + snapshots = { 'prechange': getattr(instance, '_prechange_snapshot', None), - 'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None, + 'postchange': None, } + if action != ObjectChangeActionChoices.ACTION_DELETE: + # Use model's serialize() method if defined; fall back to serialize_object + if hasattr(instance, 'serialize_object'): + snapshots['postchange'] = instance.serialize_object() + else: + snapshots['postchange'] = serialize_object(instance) + + return snapshots def generate_signature(request_body, secret): diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 817da526b..6b2ee1f94 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -49,11 +49,19 @@ class ChangeLoggingMixin(models.Model): class Meta: abstract = True + def serialize_object(self): + """ + Return a JSON representation of the instance. Models can override this method to replace or extend the default + serialization logic provided by the `serialize_object()` utility function. + """ + return serialize_object(self) + def snapshot(self): """ - Save a snapshot of the object's current state in preparation for modification. + Save a snapshot of the object's current state in preparation for modification. The snapshot is saved as + `_prechange_snapshot` on the instance. """ - self._prechange_snapshot = serialize_object(self) + self._prechange_snapshot = self.serialize_object() def to_objectchange(self, action): """ @@ -69,7 +77,7 @@ class ChangeLoggingMixin(models.Model): if hasattr(self, '_prechange_snapshot'): objectchange.prechange_data = self._prechange_snapshot if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE): - objectchange.postchange_data = serialize_object(self) + objectchange.postchange_data = self.serialize_object() return objectchange From 277c2ff8697ea85854bee0cb56a8964331331069 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 1 Jul 2022 16:36:24 -0400 Subject: [PATCH 126/245] Closes #8171: Populate next available address when cloning an IP --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/models/ip.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 0e3cfd35f..92401c217 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -25,6 +25,7 @@ * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit * [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location +* [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index db662f49c..0bc0e2364 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -857,6 +857,25 @@ class IPAddress(NetBoxModel): address__net_host=str(self.address.ip) ).exclude(pk=self.pk) + def get_next_available_ip(self): + """ + Return the next available IP address within this IP's network (if any) + """ + if self.address and self.address.broadcast: + start_ip = self.address.ip + 1 + end_ip = self.address.broadcast - 1 + if start_ip <= end_ip: + available_ips = netaddr.IPSet(netaddr.IPRange(start_ip, end_ip)) + available_ips -= netaddr.IPSet([ + address.ip for address in IPAddress.objects.filter( + vrf=self.vrf, + address__gt=self.address, + address__net_contained_or_equal=self.address.cidr + ).values_list('address', flat=True) + ]) + if available_ips: + return next(iter(available_ips)) + def clean(self): super().clean() @@ -907,6 +926,15 @@ class IPAddress(NetBoxModel): super().save(*args, **kwargs) + def clone(self): + attrs = super().clone() + + # Populate the address field with the next available IP (if any) + if next_available_ip := self.get_next_available_ip(): + attrs['address'] = next_available_ip + + return attrs + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.assigned_object From b1729f212799a468184ddf65faedb14a4a9555c3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:02:29 -0500 Subject: [PATCH 127/245] Change Virtual Circuits to L2VPN Co-authored-by: Jeremy Stretch --- netbox/ipam/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 36102f853..d331a0f7d 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -439,7 +439,7 @@ class ServiceSerializer(NetBoxModelSerializer): ] # -# Virtual Circuits +# L2VPN # From aa856e75e8897d8a4a32b571ae7f44521b0f8695 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:02:44 -0500 Subject: [PATCH 128/245] Change Virtual Circuits to L2VPN Co-authored-by: Jeremy Stretch --- netbox/ipam/api/nested_serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 07a7c9598..e74d60fb2 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -195,7 +195,7 @@ class NestedServiceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] # -# Virtual Circuits +# L2VPN # From 8e39e7f8306f96080d6d13ce829b4376402bc094 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:03:07 -0500 Subject: [PATCH 129/245] Change API urls to plural form Co-authored-by: Jeremy Stretch --- netbox/ipam/api/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index b588b6974..20e31f4d4 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -46,8 +46,8 @@ router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) # L2VPN -router.register('l2vpn', views.L2VPNViewSet) -router.register('l2vpn-termination', views.L2VPNTerminationViewSet) +router.register('l2vpns', views.L2VPNViewSet) +router.register('l2vpn-terminations', views.L2VPNTerminationViewSet) app_name = 'ipam-api' From dbb1773e158d3243bcbcf9a950ab266ac1fa7a1b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:04:33 -0500 Subject: [PATCH 130/245] Remove extraneous imports Co-authored-by: Jeremy Stretch --- netbox/ipam/forms/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 7ef47ed2f..5f4b37729 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -8,8 +8,6 @@ from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * -from ipam.models import ASN -from ipam.models.l2vpn import L2VPN, L2VPNTermination from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from tenancy.models import Tenant From 0004b834fb21230c0a494d262138495fd9bc03d3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:17:50 -0500 Subject: [PATCH 131/245] Commit fixes Jeremy suggested Co-authored-by: Jeremy Stretch --- netbox/ipam/api/serializers.py | 1 - netbox/ipam/api/views.py | 4 ++-- netbox/ipam/forms/models.py | 3 +++ netbox/ipam/models/l2vpn.py | 12 ++++++++---- netbox/netbox/navigation_menu.py | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index d331a0f7d..c6e0027f1 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -483,7 +483,6 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', 'assigned_object', - # Extra Fields 'tags', 'custom_fields', 'created', 'last_updated' ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f5a61c031..0407c6d39 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -18,7 +18,7 @@ from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import serializers -from ..models.l2vpn import L2VPN, L2VPNTermination +from ipam.models import L2VPN, L2VPNTermination class IPAMRootView(APIRootView): @@ -159,7 +159,7 @@ class ServiceViewSet(NetBoxModelViewSet): class L2VPNViewSet(NetBoxModelViewSet): - queryset = L2VPN.objects + queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags') serializer_class = serializers.L2VPNSerializer filterset_class = filtersets.L2VPNFilterSet diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 5f4b37729..bd1dce6fd 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -893,6 +893,9 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): fields = ( 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' ) + widgets = { + 'type': StaticSelect(), + } class L2VPNTerminationForm(NetBoxModelForm): diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index b086fa109..46cad72f8 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -34,7 +34,7 @@ class L2VPN(NetBoxModel): description = models.TextField(null=True, blank=True) tenant = models.ForeignKey( to='tenancy.Tenant', - on_delete=models.SET_NULL, + on_delete=models.PROTECT, related_name='l2vpns', blank=True, null=True @@ -85,7 +85,6 @@ class L2VPNTermination(NetBoxModel): class Meta: ordering = ('l2vpn',) verbose_name = 'L2VPN Termination' - constraints = ( models.UniqueConstraint( fields=('assigned_object_type', 'assigned_object_id'), @@ -112,5 +111,10 @@ class L2VPNTermination(NetBoxModel): # Only check if L2VPN is set and is of type P2P if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P: - if L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() >= 2: - raise ValidationError(f'P2P Type L2VPNs can only have 2 terminations; first delete a termination') + terminations_count = L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() + if terminations_count >= 2: + l2vpn_type = self.l2vpn.get_type_display() + raise ValidationError( + f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already ' + f'defined.' + ) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index f2245f68b..513cf4d9e 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -263,7 +263,7 @@ IPAM_MENU = Menu( MenuGroup( label='L2VPNs', items=( - get_model_item('ipam', 'l2vpn', 'L2VPN'), + get_model_item('ipam', 'l2vpn', 'L2VPNs'), get_model_item('ipam', 'l2vpntermination', 'Terminations'), ), ), From 30350e3b40dbf699fd9d9ae1c8998e41dae2d6fb Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 08:57:15 -0500 Subject: [PATCH 132/245] More fixes as a result of code review --- netbox/ipam/api/serializers.py | 7 +--- netbox/ipam/choices.py | 16 ++++---- netbox/ipam/forms/models.py | 32 +++++++++++++-- netbox/ipam/models/l2vpn.py | 18 +++------ netbox/ipam/tables/l2vpn.py | 23 ++++++++++- netbox/ipam/urls.py | 40 +++++++++---------- .../templates/ipam/l2vpntermination_edit.html | 16 ++++++-- 7 files changed, 98 insertions(+), 54 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c6e0027f1..9cde08374 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -464,9 +464,7 @@ class L2VPNSerializer(NetBoxModelSerializer): model = L2VPN fields = [ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', - 'description', 'tenant', - # Extra Fields - 'tags', 'custom_fields', 'created', 'last_updated' + 'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] @@ -482,8 +480,7 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): model = L2VPNTermination fields = [ 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', - 'assigned_object', - 'tags', 'custom_fields', 'created', 'last_updated' + 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 72cd4ff73..298baa643 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -191,6 +191,14 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_VPWS, 'VPWS'), (TYPE_VPLS, 'VPLS'), )), + ('VXLAN', ( + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), + ('L2VPN E-VPN', ( + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )), ('E-Line', ( (TYPE_EPL, 'EPL'), (TYPE_EVPL, 'EVPL'), @@ -203,14 +211,6 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_EPTREE, 'Ethernet Private Tree'), (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), )), - ('VXLAN', ( - (TYPE_VXLAN, 'VXLAN'), - (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), - )), - ('L2VPN E-VPN', ( - (TYPE_MPLS_EVPN, 'MPLS EVPN'), - (TYPE_PBB_EVPN, 'PBB EVPN'), - )) ) P2P = ( diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index bd1dce6fd..d2797c1cf 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -929,6 +929,20 @@ class L2VPNTerminationForm(NetBoxModelForm): } ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + query_params={} + ) + + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine' + } + ) + class Meta: model = L2VPNTermination fields = ('l2vpn', ) @@ -943,6 +957,8 @@ class L2VPNTerminationForm(NetBoxModelForm): initial['interface'] = instance.assigned_object elif type(instance.assigned_object) is VLAN: initial['vlan'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['vminterface'] = instance.assigned_object kwargs['initial'] = initial super().__init__(*args, **kwargs) @@ -950,11 +966,21 @@ class L2VPNTerminationForm(NetBoxModelForm): def clean(self): super().clean() - if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + interface = self.cleaned_data.get('interface') + vlan = self.cleaned_data.get('vlan') + vminterface = self.cleaned_data.get('vminterface') + + if not (interface or vlan or vminterface): raise ValidationError('You must have either a interface or a VLAN') - if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + if interface and vlan and vminterface: + raise ValidationError('Cannot assign a interface, vlan and vminterface') + elif interface and vlan: raise ValidationError('Cannot assign both a interface and vlan') + elif interface and vminterface: + raise ValidationError('Cannot assign both a interface and vminterface') + elif vlan and vminterface: + raise ValidationError('Cannot assign both a vlan and vminterface') - obj = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') + obj = interface or vlan or vminterface self.instance.assigned_object = obj diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 46cad72f8..dd8c51984 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -60,23 +60,15 @@ class L2VPNTermination(NetBoxModel): l2vpn = models.ForeignKey( to='ipam.L2VPN', on_delete=models.CASCADE, - related_name='terminations', - blank=False, - null=False + related_name='terminations' ) - assigned_object_type = models.ForeignKey( to=ContentType, limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, - related_name='+', - blank=True, - null=True - ) - assigned_object_id = models.PositiveBigIntegerField( - blank=True, - null=True + related_name='+' ) + assigned_object_id = models.PositiveBigIntegerField() assigned_object = GenericForeignKey( ct_field='assigned_object_type', fk_field='assigned_object_id' @@ -95,13 +87,13 @@ class L2VPNTermination(NetBoxModel): def __str__(self): if self.pk is not None: return f'{self.assigned_object} <> {self.l2vpn}' - return '' + return super().__str__() def get_absolute_url(self): return reverse('ipam:l2vpntermination', args=[self.pk]) def clean(self): - # Only check is assigned_object is set + # Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown. if self.assigned_object: obj_id = self.assigned_object.pk obj_type = ContentType.objects.get_for_model(self.assigned_object) diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 551f692bb..a0e2f5d67 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -9,25 +9,44 @@ __all__ = ( 'L2VPNTerminationTable', ) +L2VPN_TARGETS = """ +{% for rt in value.all %} + {{ rt }}{% if not forloop.last %}
    {% endif %} +{% endfor %} +""" + class L2VPNTable(NetBoxTable): pk = columns.ToggleColumn() name = tables.Column( linkify=True ) + import_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) + export_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) class Meta(NetBoxTable.Meta): model = L2VPN - fields = ('pk', 'name', 'description', 'slug', 'type', 'tenant', 'actions') - default_columns = ('pk', 'name', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'actions') + default_columns = ('pk', 'name', 'type', 'description', 'actions') class L2VPNTerminationTable(NetBoxTable): pk = columns.ToggleColumn() + l2vpn = tables.Column( + verbose_name='L2VPN', + linkify=True + ) assigned_object_type = columns.ContentTypeColumn( verbose_name='Object Type' ) assigned_object = tables.Column( + verbose_name='Assigned Object', linkify=True, orderable=False ) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index e00b0365f..d27209fd2 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -187,25 +187,25 @@ urlpatterns = [ path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), # L2VPN - path('l2vpn/', views.L2VPNListView.as_view(), name='l2vpn_list'), - path('l2vpn/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), - path('l2vpn/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), - path('l2vpn/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), - path('l2vpn/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), - path('l2vpn//', views.L2VPNView.as_view(), name='l2vpn'), - path('l2vpn//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), - path('l2vpn//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), - path('l2vpn//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), - path('l2vpn//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), + path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), + path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), + path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), + path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), + path('l2vpns//', views.L2VPNView.as_view(), name='l2vpn'), + path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), + path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), + path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), + path('l2vpns//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), - path('l2vpn-termination/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), - path('l2vpn-termination/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), - path('l2vpn-termination/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), - path('l2vpn-termination/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), - path('l2vpn-termination/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), - path('l2vpn-termination//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), - path('l2vpn-termination//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), - path('l2vpn-termination//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), - path('l2vpn-termination//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), - path('l2vpn-termination//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), + path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), + path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), + path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), + path('l2vpn-terminations//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), + path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), + path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), + path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), ] diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index 3fb0460b5..4ba079eb5 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -12,7 +12,7 @@
    - {% render_field form.device %} -
    +
    + {% render_field form.device %} {% render_field form.vlan %}
    + {% render_field form.device %} {% render_field form.interface %}
    +
    + {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
    From 5bcc3a3fb9636d0b24c42a46383dc964f01287e3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:00:33 -0500 Subject: [PATCH 133/245] Update docs --- docs/models/ipam/l2vpn.md | 2 ++ docs/models/ipam/l2vpntermination.md | 5 ++++- netbox/ipam/forms/models.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md index 9c50a6407..9f9b4703c 100644 --- a/docs/models/ipam/l2vpn.md +++ b/docs/models/ipam/l2vpn.md @@ -17,3 +17,5 @@ Each L2VPN instance must have one of the following type associated with it: * MPLS-EVPN * PBB-EVPN +!!!note + Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add 2 terminations to a given L2VPN. diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md index 9135f72a3..cc1843639 100644 --- a/docs/models/ipam/l2vpntermination.md +++ b/docs/models/ipam/l2vpntermination.md @@ -9,4 +9,7 @@ The following types of L2VPN's are considered point-to-point: * VPWS * EPL * EP-LAN -* EP-TREE \ No newline at end of file +* EP-TREE + +!!!note + Choosing any of the above types of L2VPN's will result in only being able to add 2 terminations to a given L2VPN. diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d2797c1cf..43e33dd4d 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -976,7 +976,7 @@ class L2VPNTerminationForm(NetBoxModelForm): if interface and vlan and vminterface: raise ValidationError('Cannot assign a interface, vlan and vminterface') elif interface and vlan: - raise ValidationError('Cannot assign both a interface and vlan') + raise Validatio`nError('Cannot assign both a interface and vlan') elif interface and vminterface: raise ValidationError('Cannot assign both a interface and vminterface') elif vlan and vminterface: From f1c8926252e923589161ede1cc7b4cd6432806c1 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:01:08 -0500 Subject: [PATCH 134/245] Fix error --- netbox/ipam/forms/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 43e33dd4d..d2797c1cf 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -976,7 +976,7 @@ class L2VPNTerminationForm(NetBoxModelForm): if interface and vlan and vminterface: raise ValidationError('Cannot assign a interface, vlan and vminterface') elif interface and vlan: - raise Validatio`nError('Cannot assign both a interface and vlan') + raise ValidationError('Cannot assign both a interface and vlan') elif interface and vminterface: raise ValidationError('Cannot assign both a interface and vminterface') elif vlan and vminterface: From 878c465c56b5a656c4f99ebc9499d11274a7692e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 6 Jul 2022 09:10:10 -0500 Subject: [PATCH 135/245] Update Termination table rendering on L2VPN View --- netbox/templates/ipam/l2vpn.html | 34 ++------------------------------ 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index 59cc6234b..130940b02 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -59,39 +59,9 @@
    -
    L2VPN Terminations
    +
    Terminations
    - {% with terminations=object.terminations.all %} - {% if terminations.exists %} - - - - - - - {% for termination in terminations %} - - - - - - {% endfor %} -
    Termination TypeTermination
    {{ termination.assigned_object|meta:"verbose_name" }}{{ termination.assigned_object|linkify }} - {% if perms.ipam.change_l2vpntermination %} - - - - {% endif %} - {% if perms.ipam.delete_l2vpntermination %} - - - - {% endif %} -
    - {% else %} -
    None
    - {% endif %} - {% endwith %} + {% render_table terminations_table 'inc/table.html' %}
    {% if perms.ipam.add_l2vpntermination %}
    - Cancel + Cancel
    diff --git a/netbox/users/tables.py b/netbox/users/tables.py new file mode 100644 index 000000000..27547b955 --- /dev/null +++ b/netbox/users/tables.py @@ -0,0 +1,42 @@ +from .models import Token +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'TokenTable', +) + + +TOKEN = """{{ value }}""" + +ALLOWED_IPS = """{{ value|join:", " }}""" + +COPY_BUTTON = """ + + + +""" + + +class TokenTable(NetBoxTable): + key = columns.TemplateColumn( + template_code=TOKEN + ) + write_enabled = columns.BooleanColumn( + verbose_name='Write' + ) + created = columns.DateColumn() + expired = columns.DateColumn() + last_used = columns.DateTimeColumn() + allowed_ips = columns.TemplateColumn( + template_code=ALLOWED_IPS + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=COPY_BUTTON + ) + + class Meta(NetBoxTable.Meta): + model = Token + fields = ( + 'pk', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'description', + ) diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 0cfcfc9de..62b17a663 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -2,7 +2,7 @@ from django.urls import path from . import views -app_name = 'user' +app_name = 'users' urlpatterns = [ path('profile/', views.ProfileView.as_view(), name='profile'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 6a923e77e..aabdd6774 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -21,6 +21,7 @@ from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .models import Token +from .tables import TokenTable # @@ -157,7 +158,7 @@ class UserConfigView(LoginRequiredMixin, View): form.save() messages.success(request, "Your preferences have been updated.") - return redirect('user:preferences') + return redirect('users:preferences') return render(request, self.template_name, { 'form': form, @@ -172,7 +173,7 @@ class ChangePasswordView(LoginRequiredMixin, View): # LDAP users cannot change their password here if getattr(request.user, 'ldap_username', None): messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") - return redirect('user:profile') + return redirect('users:profile') form = PasswordChangeForm(user=request.user) @@ -187,7 +188,7 @@ class ChangePasswordView(LoginRequiredMixin, View): form.save() update_session_auth_hash(request, form.user) messages.success(request, "Your password has been changed successfully.") - return redirect('user:profile') + return redirect('users:profile') return render(request, self.template_name, { 'form': form, @@ -204,10 +205,13 @@ class TokenListView(LoginRequiredMixin, View): def get(self, request): tokens = Token.objects.filter(user=request.user) + table = TokenTable(tokens) + table.configure(request) return render(request, 'users/api_tokens.html', { 'tokens': tokens, 'active_tab': 'api-tokens', + 'table': table, }) @@ -225,7 +229,7 @@ class TokenEditView(LoginRequiredMixin, View): return render(request, 'generic/object_edit.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) def post(self, request, pk=None): @@ -248,12 +252,12 @@ class TokenEditView(LoginRequiredMixin, View): if '_addanother' in request.POST: return redirect(request.path) else: - return redirect('user:token_list') + return redirect('users:token_list') return render(request, 'generic/object_edit.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) @@ -263,14 +267,14 @@ class TokenDeleteView(LoginRequiredMixin, View): token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) initial_data = { - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), } form = ConfirmationForm(initial=initial_data) return render(request, 'generic/object_delete.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) def post(self, request, pk): @@ -280,10 +284,10 @@ class TokenDeleteView(LoginRequiredMixin, View): if form.is_valid(): token.delete() messages.success(request, "Token deleted") - return redirect('user:token_list') + return redirect('users:token_list') return render(request, 'generic/object_delete.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) From 8a8ada852959c745103e8351cf5d4ea83a96c114 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 11 Jul 2022 16:14:46 -0400 Subject: [PATCH 153/245] #9177: Use TenancyColumnsMixin (from #9686) --- netbox/wireless/tables/wirelesslan.py | 9 ++++----- netbox/wireless/tables/wirelesslink.py | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index fc791d730..af0cdae88 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -2,7 +2,7 @@ import django_tables2 as tables from dcim.models import Interface from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin from wireless.models import * __all__ = ( @@ -33,14 +33,13 @@ class WirelessLANGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'wirelesslan_count', 'description') -class WirelessLANTable(NetBoxTable): +class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): ssid = tables.Column( linkify=True ) group = tables.Column( linkify=True ) - tenant = TenantColumn() interface_count = tables.Column( verbose_name='Interfaces' ) @@ -51,8 +50,8 @@ class WirelessLANTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( - 'pk', 'ssid', 'group', 'tenant', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', - 'auth_psk', 'tags', 'created', 'last_updated', + 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'description', 'vlan', 'interface_count', 'auth_type', + 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py index 6a45a21ae..0135ce620 100644 --- a/netbox/wireless/tables/wirelesslink.py +++ b/netbox/wireless/tables/wirelesslink.py @@ -1,7 +1,7 @@ import django_tables2 as tables from netbox.tables import NetBoxTable, columns -from tenancy.tables import TenantColumn +from tenancy.tables import TenancyColumnsMixin from wireless.models import * __all__ = ( @@ -9,7 +9,7 @@ __all__ = ( ) -class WirelessLinkTable(NetBoxTable): +class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable): id = tables.Column( linkify=True, verbose_name='ID' @@ -29,7 +29,6 @@ class WirelessLinkTable(NetBoxTable): interface_b = tables.Column( linkify=True ) - tenant = TenantColumn() tags = columns.TagColumn( url_name='wireless:wirelesslink_list' ) @@ -37,8 +36,8 @@ class WirelessLinkTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLink fields = ( - 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'tenant', 'description', - 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'tenant', + 'tenant_group', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', From 2264937f815acef7500a79796b70482cb33a15d3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 11 Jul 2022 17:11:58 -0400 Subject: [PATCH 154/245] Consolidate custom field migrations --- ..._name.py => 0074_customfield_extensions.py} | 5 +++++ ...ions.py => 0075_configcontext_locations.py} | 2 +- .../0075_customfield_ui_visibility.py | 18 ------------------ netbox/ipam/migrations/0059_l2vpn.py | 2 +- 4 files changed, 7 insertions(+), 20 deletions(-) rename netbox/extras/migrations/{0074_customfield_group_name.py => 0074_customfield_extensions.py} (75%) rename netbox/extras/migrations/{0076_configcontext_locations.py => 0075_configcontext_locations.py} (88%) delete mode 100644 netbox/extras/migrations/0075_customfield_ui_visibility.py diff --git a/netbox/extras/migrations/0074_customfield_group_name.py b/netbox/extras/migrations/0074_customfield_extensions.py similarity index 75% rename from netbox/extras/migrations/0074_customfield_group_name.py rename to netbox/extras/migrations/0074_customfield_extensions.py index e1be76b1f..6ca8b958f 100644 --- a/netbox/extras/migrations/0074_customfield_group_name.py +++ b/netbox/extras/migrations/0074_customfield_extensions.py @@ -19,4 +19,9 @@ class Migration(migrations.Migration): name='group_name', field=models.CharField(blank=True, max_length=50), ), + migrations.AddField( + model_name='customfield', + name='ui_visibility', + field=models.CharField(default='read-write', max_length=50), + ), ] diff --git a/netbox/extras/migrations/0076_configcontext_locations.py b/netbox/extras/migrations/0075_configcontext_locations.py similarity index 88% rename from netbox/extras/migrations/0076_configcontext_locations.py rename to netbox/extras/migrations/0075_configcontext_locations.py index f9b3a664b..853aec4f7 100644 --- a/netbox/extras/migrations/0076_configcontext_locations.py +++ b/netbox/extras/migrations/0075_configcontext_locations.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ ('dcim', '0156_location_status'), - ('extras', '0075_customfield_ui_visibility'), + ('extras', '0074_customfield_extensions'), ] operations = [ diff --git a/netbox/extras/migrations/0075_customfield_ui_visibility.py b/netbox/extras/migrations/0075_customfield_ui_visibility.py deleted file mode 100644 index 29ee65516..000000000 --- a/netbox/extras/migrations/0075_customfield_ui_visibility.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-23 20:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0074_customfield_group_name'), - ] - - operations = [ - migrations.AddField( - model_name='customfield', - name='ui_visibility', - field=models.CharField(default='read-write', max_length=50), - ), - ] diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py index 5662049c3..ef670ddea 100644 --- a/netbox/ipam/migrations/0059_l2vpn.py +++ b/netbox/ipam/migrations/0059_l2vpn.py @@ -9,7 +9,7 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('extras', '0076_configcontext_locations'), + ('extras', '0075_configcontext_locations'), ('contenttypes', '0002_remove_content_type_name'), ('tenancy', '0007_contact_link'), ('ipam', '0058_ipaddress_nat_inside_nonunique'), From 1ddb219a0cd0232524fe18638cb7f4265fb00a48 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 11 Jul 2022 17:29:25 -0400 Subject: [PATCH 155/245] Documentation cleanup --- docs/configuration/optional-settings.md | 2 ++ docs/models/extras/customfield.md | 30 ++++++++++++++++++--- docs/models/users/objectpermission.md | 2 +- docs/models/users/token.md | 9 ++++++- docs/plugins/development/models.md | 4 +-- mkdocs.yml | 2 +- netbox/extras/webhooks.py | 2 +- netbox/netbox/views/generic/object_views.py | 3 ++- netbox/users/forms.py | 2 +- 9 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 3b1c848a7..8e5664a95 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -212,6 +212,7 @@ The following model fields support configurable choices: * `circuits.Circuit.status` * `dcim.Device.status` +* `dcim.Location.status` * `dcim.PowerFeed.status` * `dcim.Rack.status` * `dcim.Site.status` @@ -220,6 +221,7 @@ The following model fields support configurable choices: * `ipam.IPRange.status` * `ipam.Prefix.status` * `ipam.VLAN.status` +* `virtualization.Cluster.status` * `virtualization.VirtualMachine.status` The following colors are supported: diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index e0c01688d..3f6860758 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -26,11 +26,35 @@ Each custom field must have a name. This should be a simple database-friendly st Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. -The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. - A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. -### Custom Field Validation +### Filtering + +The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. + +### Grouping + +!!! note + This feature was introduced in NetBox v3.3. + +Related custom fields can be grouped together within the UI by assigning each the same group name. When at least one custom field for an object type has a group defined, it will appear under the group heading within the custom fields panel under the object view. All custom fields with the same group name will appear under that heading. (Note that the group names must match exactly, or each will appear as a separate heading.) + +This parameter has no effect on the API representation of custom field data. + +### Visibility + +!!! note + This feature was introduced in NetBox v3.3. + +When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI. + +* **Read/write** (default): The custom field is included when viewing and editing objects. +* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.) +* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users. + +Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API. + +### Validation NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type: diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md index 075a2cae5..82dbc955a 100644 --- a/docs/models/users/objectpermission.md +++ b/docs/models/users/objectpermission.md @@ -54,7 +54,7 @@ To achieve a logical OR with a different set of constraints, define multiple obj Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. -### Tokens +### User Token !!! info "This feature was introduced in NetBox v3.3" diff --git a/docs/models/users/token.md b/docs/models/users/token.md index 367444477..f6c5bfe80 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -9,4 +9,11 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. -Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. Tokens can also be restricted by IP range: If defined, authentication for API clients connecting from an IP address outside these ranges will fail. +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. + +### Client IP Restriction + +!!! note + This feature was introduced in NetBox v3.3. + +Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.) diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 43e3cad9a..6d7075b80 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -37,7 +37,7 @@ This class performs two crucial functions: 1. Apply any fields, methods, and/or attributes necessary to the operation of these features 2. Register the model with NetBox as utilizing these features -Simply subclass BaseModel when defining a model in your plugin: +Simply subclass NetBoxModel when defining a model in your plugin: ```python # models.py @@ -54,7 +54,7 @@ class MyModel(NetBoxModel): !!! info This method was introduced in NetBox v3.3. -The `NetBoxModel` class includes a `clone()` method to be used for gathering attriubtes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. +The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content: diff --git a/mkdocs.yml b/mkdocs.yml index 88a2794e8..5e9f4b842 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -131,7 +131,7 @@ nav: - NetBox Shell: 'administration/netbox-shell.md' - REST API: - Overview: 'rest-api/overview.md' - - Filtering: 'rest-api/filtering.md' + - Filtering & Ordering: 'rest-api/filtering.md' - Authentication: 'rest-api/authentication.md' - GraphQL API: - Overview: 'graphql-api/overview.md' diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 334539026..bef90a245 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -31,7 +31,7 @@ def get_snapshots(instance, action): 'postchange': None, } if action != ObjectChangeActionChoices.ACTION_DELETE: - # Use model's serialize() method if defined; fall back to serialize_object + # Use model's serialize_object() method if defined; fall back to serialize_object() utility function if hasattr(instance, 'serialize_object'): snapshots['postchange'] = instance.serialize_object() else: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index dc078a7e2..cb3f58123 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -72,7 +72,8 @@ class ObjectView(BaseObjectView): class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): """ - Display a table of child objects associated with the parent object. + Display a table of child objects associated with the parent object. For example, NetBox uses this to display + the set of child IP addresses within a parent prefix. Attributes: child_model: The model class which represents the child objects diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 8692eb050..b4e86461d 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -106,7 +106,7 @@ class TokenForm(BootstrapMixin, forms.ModelForm): required=False, label='Allowed IPs', help_text='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"', + 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64', ) class Meta: From 53372a747109479f632fc916244d14fb75253f60 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 11 Jul 2022 21:51:39 -0400 Subject: [PATCH 156/245] #8157: General cleanup & fix tests --- netbox/ipam/filtersets.py | 25 ++-- netbox/ipam/forms/bulk_edit.py | 9 +- netbox/ipam/forms/bulk_import.py | 2 +- netbox/ipam/forms/filtersets.py | 38 ++++-- netbox/ipam/forms/models.py | 6 +- netbox/ipam/migrations/0059_l2vpn.py | 6 +- netbox/ipam/models/ip.py | 2 +- netbox/ipam/models/l2vpn.py | 11 +- netbox/ipam/tables/l2vpn.py | 11 +- netbox/ipam/tests/test_api.py | 2 - netbox/ipam/tests/test_filtersets.py | 126 ++++++++++++------ netbox/ipam/tests/test_views.py | 37 ++--- netbox/templates/ipam/l2vpn.html | 98 +++++++------- .../templates/ipam/l2vpntermination_edit.html | 4 +- 14 files changed, 213 insertions(+), 164 deletions(-) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index f682009ee..edd1867ed 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -930,8 +930,11 @@ class ServiceFilterSet(NetBoxModelFilterSet): # L2VPN # - class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=L2VPNTypeChoices, + null_value=None + ) import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), @@ -972,10 +975,10 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): label='L2VPN (ID)', ) l2vpn = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn__name', + field_name='l2vpn__slug', queryset=L2VPN.objects.all(), - to_field_name='name', - label='L2VPN (name)', + to_field_name='slug', + label='L2VPN (slug)', ) device = MultiValueCharFilter( method='filter_device', @@ -987,17 +990,16 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): field_name='pk', label='Device (ID)', ) - interface = django_filters.ModelMultipleChoiceFilter( - field_name='interface__name', - queryset=Interface.objects.all(), - to_field_name='name', - label='Interface (name)', - ) interface_id = django_filters.ModelMultipleChoiceFilter( field_name='interface', queryset=Interface.objects.all(), label='Interface (ID)', ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.all(), + label='VM Interface (ID)', + ) vlan = django_filters.ModelMultipleChoiceFilter( field_name='vlan__name', queryset=VLAN.objects.all(), @@ -1013,10 +1015,11 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): queryset=VLAN.objects.all(), label='VLAN (ID)', ) + assigned_object_type = ContentTypeFilter() class Meta: model = L2VPNTermination - fields = ['id', ] + fields = ('id', 'assigned_object_type_id') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 50fc51522..5f579b07f 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -8,7 +8,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect, + add_blank_choice, BulkEditNullBooleanSelect, DynamicModelChoiceField, NumericArrayField, StaticSelect, DynamicModelMultipleChoiceField, ) @@ -445,6 +445,11 @@ class ServiceBulkEditForm(ServiceTemplateBulkEditForm): class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + type = forms.ChoiceField( + choices=add_blank_choice(L2VPNTypeChoices), + required=False, + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -456,7 +461,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): model = L2VPN fieldsets = ( - (None, ('tenant', 'description')), + (None, ('type', 'description', 'tenant')), ) nullable_fields = ('tenant', 'description',) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index b8dd1c54c..880d2722f 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -438,7 +438,7 @@ class L2VPNCSVForm(NetBoxModelCSVForm): ) type = CSVChoiceField( choices=L2VPNTypeChoices, - help_text='IP protocol' + help_text='L2VPN type' ) class Meta: diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 795cfe378..384a4da33 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,18 +1,19 @@ from django import forms +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Location, Rack, Region, Site, SiteGroup, Device -from virtualization.models import VirtualMachine from ipam.choices import * from ipam.constants import * from ipam.models import * -from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( - add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField, StaticSelect, - TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) +from virtualization.models import VirtualMachine __all__ = ( 'AggregateFilterForm', @@ -482,7 +483,8 @@ class ServiceFilterForm(ServiceTemplateFilterForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( - (None, ('type', )), + (None, ('q', 'tag')), + ('Attributes', ('type', 'import_target_id', 'export_target_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) type = forms.ChoiceField( @@ -490,17 +492,31 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, widget=StaticSelect() ) + import_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Import targets') + ) + export_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Export targets') + ) + tag = TagFilterField(model) class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('l2vpn', )), + (None, ('l2vpn_id', 'assigned_object_type_id')), ) - l2vpn = DynamicModelChoiceField( + l2vpn_id = DynamicModelChoiceField( queryset=L2VPN.objects.all(), - required=True, - query_params={}, - label='L2VPN', - fetch_trigger='open' + required=False, + label='L2VPN' + ) + assigned_object_type_id = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + required=False, + label='Object type' ) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 3986eee32..415c952be 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -916,7 +916,8 @@ class L2VPNTerminationForm(NetBoxModelForm): required=False, query_params={ 'available_on_device': '$device' - } + }, + label='VLAN' ) interface = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -935,7 +936,8 @@ class L2VPNTerminationForm(NetBoxModelForm): required=False, query_params={ 'virtual_machine_id': '$virtual_machine' - } + }, + label='Interface' ) class Meta: diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py index ef670ddea..7436989f7 100644 --- a/netbox/ipam/migrations/0059_l2vpn.py +++ b/netbox/ipam/migrations/0059_l2vpn.py @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('slug', models.SlugField()), ('type', models.CharField(max_length=50)), ('identifier', models.BigIntegerField(blank=True, null=True, unique=True)), - ('description', models.TextField(blank=True, null=True)), + ('description', models.CharField(blank=True, max_length=200)), ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')), ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), @@ -35,7 +35,7 @@ class Migration(migrations.Migration): ], options={ 'verbose_name': 'L2VPN', - 'ordering': ('identifier', 'name'), + 'ordering': ('name', 'identifier'), }, ), migrations.CreateModel( @@ -51,7 +51,7 @@ class Migration(migrations.Migration): ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ - 'verbose_name': 'L2VPN Termination', + 'verbose_name': 'L2VPN termination', 'ordering': ('l2vpn',), }, ), diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 0bc0e2364..ee5de8cf4 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -931,7 +931,7 @@ class IPAddress(NetBoxModel): # Populate the address field with the next available IP (if any) if next_available_ip := self.get_next_available_ip(): - attrs['address'] = next_available_ip + attrs['address'] = f'{next_available_ip}/{self.address.prefixlen}' return attrs diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index dd8c51984..5d85fe915 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -31,7 +31,10 @@ class L2VPN(NetBoxModel): related_name='exporting_l2vpns', blank=True ) - description = models.TextField(null=True, blank=True) + description = models.CharField( + max_length=200, + blank=True + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -44,7 +47,7 @@ class L2VPN(NetBoxModel): ) class Meta: - ordering = ('identifier', 'name') + ordering = ('name', 'identifier') verbose_name = 'L2VPN' def __str__(self): @@ -76,7 +79,7 @@ class L2VPNTermination(NetBoxModel): class Meta: ordering = ('l2vpn',) - verbose_name = 'L2VPN Termination' + verbose_name = 'L2VPN termination' constraints = ( models.UniqueConstraint( fields=('assigned_object_type', 'assigned_object_id'), @@ -102,7 +105,7 @@ class L2VPNTermination(NetBoxModel): raise ValidationError(f'L2VPN Termination already assigned ({self.assigned_object})') # Only check if L2VPN is set and is of type P2P - if self.l2vpn and self.l2vpn.type in L2VPNTypeChoices.P2P: + if hasattr(self, 'l2vpn') and self.l2vpn.type in L2VPNTypeChoices.P2P: terminations_count = L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() if terminations_count >= 2: l2vpn_type = self.l2vpn.get_type_display() diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index a0e2f5d67..5be525343 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -1,8 +1,8 @@ import django_tables2 as tables -from ipam.models import * -from ipam.models.l2vpn import L2VPN, L2VPNTermination +from ipam.models import L2VPN, L2VPNTermination from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenancyColumnsMixin __all__ = ( 'L2VPNTable', @@ -16,7 +16,7 @@ L2VPN_TARGETS = """ """ -class L2VPNTable(NetBoxTable): +class L2VPNTable(TenancyColumnsMixin, NetBoxTable): pk = columns.ToggleColumn() name = tables.Column( linkify=True @@ -32,7 +32,10 @@ class L2VPNTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = L2VPN - fields = ('pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'actions') + fields = ( + 'pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group', + 'actions', + ) default_columns = ('pk', 'name', 'type', 'description', 'actions') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index a5ebef2c7..3fef04194 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -970,7 +970,6 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): VLAN(name='VLAN 6', vid=656), VLAN(name='VLAN 7', vid=657) ) - VLAN.objects.bulk_create(vlans) l2vpns = ( @@ -985,7 +984,6 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) ) - L2VPNTermination.objects.bulk_create(l2vpnterminations) cls.create_data = [ diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 2b5fb0759..9106a4965 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1,6 +1,8 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from netaddr import IPNetwork +from dcim.choices import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.filtersets import * @@ -1472,12 +1474,54 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + route_targets = ( + RouteTarget(name='1:1'), + RouteTarget(name='1:2'), + RouteTarget(name='1:3'), + RouteTarget(name='2:1'), + RouteTarget(name='2:2'), + RouteTarget(name='2:3'), + ) + RouteTarget.objects.bulk_create(route_targets) + l2vpns = ( - L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001), + L2VPN(name='L2VPN 2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002), + L2VPN(name='L2VPN 3', type=L2VPNTypeChoices.TYPE_VPLS), ) L2VPN.objects.bulk_create(l2vpns) + l2vpns[0].import_targets.add(route_targets[0]) + l2vpns[1].import_targets.add(route_targets[1]) + l2vpns[2].import_targets.add(route_targets[2]) + l2vpns[0].export_targets.add(route_targets[3]) + l2vpns[1].export_targets.add(route_targets[4]) + l2vpns[2].export_targets.add(route_targets[5]) + + def test_name(self): + params = {'name': ['L2VPN 1', 'L2VPN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_identifier(self): + params = {'identifier': ['65001', '65002']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_import_targets(self): + route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2']) + params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'import_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_export_targets(self): + route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2']) + params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'export_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -1486,44 +1530,33 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - - 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) - device_role = DeviceRole.objects.create(name='Switch') - device = Device.objects.create( - name='Device 1', - site=site, - device_type=device_type, - device_role=device_role, - status='active' - ) - + device = create_test_device('Device 1') interfaces = ( - Interface(name='Interface 1', device=device, type='1000baset'), - Interface(name='Interface 2', device=device, type='1000baset'), - Interface(name='Interface 3', device=device, type='1000baset'), - Interface(name='Interface 4', device=device, type='1000baset'), - Interface(name='Interface 5', device=device, type='1000baset'), - Interface(name='Interface 6', device=device, type='1000baset') + Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), ) - Interface.objects.bulk_create(interfaces) - vlans = ( - VLAN(name='VLAN 1', vid=651), - VLAN(name='VLAN 2', vid=652), - VLAN(name='VLAN 3', vid=653), - VLAN(name='VLAN 4', vid=654), - VLAN(name='VLAN 5', vid=655) + vm = create_test_virtualmachine('Virtual Machine 1') + vminterfaces = ( + VMInterface(name='Interface 1', virtual_machine=vm), + VMInterface(name='Interface 2', virtual_machine=vm), + VMInterface(name='Interface 3', virtual_machine=vm), ) + VMInterface.objects.bulk_create(vminterfaces) + vlans = ( + VLAN(name='VLAN 1', vid=101), + VLAN(name='VLAN 2', vid=102), + VLAN(name='VLAN 3', vid=103), + ) VLAN.objects.bulk_create(vlans) l2vpns = ( - L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD, + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD, ) L2VPN.objects.bulk_create(l2vpns) @@ -1534,27 +1567,34 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]), L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]), L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]), ) - L2VPNTermination.objects.bulk_create(l2vpnterminations) - def test_l2vpns(self): + def test_l2vpn(self): l2vpns = L2VPN.objects.all()[:2] params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'l2vpn': ['L2VPN 1', 'L2VPN 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - def test_interfaces(self): + def test_content_type(self): + params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_interface(self): interfaces = Interface.objects.all()[:2] params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} - qs = self.filterset(params, self.queryset).qs - results = qs.all() - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'interface': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_vlans(self): + def test_vminterface(self): + vminterfaces = VMInterface.objects.all()[:2] + params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlan(self): vlans = VLAN.objects.all()[:2] params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 890c0eae3..27520229a 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,18 +1,14 @@ import datetime -from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface -from extras.choices import ObjectChangeActionChoices -from extras.models import ObjectChange from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from users.models import ObjectPermission -from utilities.testing import ViewTestCases, create_tags, post_data +from utilities.testing import ViewTestCases, create_test_device, create_tags class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -772,9 +768,9 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): RouteTarget.objects.bulk_create(rts) l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier='650001'), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vxlan', identifier='650002'), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vxlan', identifier='650003') + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') ) L2VPN.objects.bulk_create(l2vpns) @@ -782,7 +778,7 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'name': 'L2VPN 8', 'slug': 'l2vpn-8', - 'type': 'vxlan', + 'type': L2VPNTypeChoices.TYPE_VXLAN, 'identifier': 123, 'description': 'Description', 'import_targets': [rts[0].pk], @@ -805,21 +801,9 @@ class L2VPNTerminationTestCase( @classmethod def setUpTestData(cls): - 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) - device_role = DeviceRole.objects.create(name='Switch') - device = Device.objects.create( - name='Device 1', - site=site, - device_type=device_type, - device_role=device_role, - status='active' - ) - + device = create_test_device('Device 1') interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') - l2vpn = L2VPN.objects.create(name='L2VPN 1', type='vxlan', identifier=650001) - l2vpn_vlans = L2VPN.objects.create(name='L2VPN 2', type='vxlan', identifier=650002) + l2vpn = L2VPN.objects.create(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001) vlans = ( VLAN(name='Vlan 1', vid=1001), @@ -846,9 +830,9 @@ class L2VPNTerminationTestCase( cls.csv_data = ( "l2vpn,vlan", - "L2VPN 2,Vlan 4", - "L2VPN 2,Vlan 5", - "L2VPN 2,Vlan 6", + "L2VPN 1,Vlan 4", + "L2VPN 1,Vlan 5", + "L2VPN 1,Vlan 6", ) cls.bulk_edit_data = {} @@ -857,6 +841,7 @@ class L2VPNTerminationTestCase( # Custom assertions # + # TODO: Remove this def assertInstanceEqual(self, instance, data, exclude=None, api=False): """ Override parent diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index 130940b02..44a1da818 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -6,46 +6,40 @@ {% block content %}
    -
    -
    - L2VPN Attributes -
    -
    - Name - - - - - - - - - - - - - - - - - - - - - - -
    {{ object.name|placeholder }}
    Slug{{ object.slug|placeholder }}
    Identifier{{ object.identifier|placeholder }}
    Type{{ object.get_type_display }}
    Description{{ object.description|placeholder }}
    Tenant{{ object.tenant|placeholder }}
    -
    -
    - {% include 'inc/panels/contacts.html' %} - {% plugin_left_page object %} +
    +
    L2VPN Attributes
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name|placeholder }}
    Identifier{{ object.identifier|placeholder }}
    Type{{ object.get_type_display }}
    Description{{ object.description|placeholder }}
    Tenant{{ object.tenant|linkify|placeholder }}
    +
    +
    + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpn_list' %} + {% plugin_left_page object %}
    - {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %}
    @@ -58,24 +52,24 @@
    -
    -
    Terminations
    -
    - {% render_table terminations_table 'inc/table.html' %} -
    - {% if perms.ipam.add_l2vpntermination %} - - {% endif %} +
    +
    Terminations
    +
    + {% render_table terminations_table 'inc/table.html' %} +
    + {% if perms.ipam.add_l2vpntermination %} + + {% endif %}
    +
    -
    - {% plugin_full_width_page object %} +
    + {% plugin_full_width_page object %}
    {% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index 4ba079eb5..7b4a9f50a 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -18,12 +18,12 @@ From 43b27cc052ee1fb569af3d8f62fffea3a365f3aa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Jul 2022 12:30:07 -0400 Subject: [PATCH 157/245] Misc cleanup --- docs/models/dcim/interface.md | 3 +++ docs/models/ipam/l2vpn.md | 4 ++-- docs/models/ipam/l2vpntermination.md | 8 ++++---- netbox/circuits/filtersets.py | 2 +- netbox/dcim/choices.py | 4 ++-- netbox/dcim/forms/bulk_edit.py | 6 ++++-- netbox/dcim/models/device_components.py | 4 ++++ netbox/dcim/models/devices.py | 13 ++++++++++++- netbox/dcim/svg/racks.py | 3 ++- netbox/dcim/tables/sites.py | 1 + netbox/templates/dcim/frontport.html | 6 +++++- netbox/templates/dcim/rearport.html | 6 +++++- .../virtualization/virtualmachine/base.html | 8 +++++++- netbox/virtualization/forms/models.py | 9 ++++++--- 14 files changed, 58 insertions(+), 19 deletions(-) diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index e3237c2ee..87a1411b9 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -13,6 +13,9 @@ Physical interfaces may be arranged into a link aggregation group (LAG) and asso ### Power over Ethernet (PoE) +!!! note + This feature was added in NetBox v3.3. + Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs). ### Wireless Interfaces diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md index 9f9b4703c..214b8aab0 100644 --- a/docs/models/ipam/l2vpn.md +++ b/docs/models/ipam/l2vpn.md @@ -17,5 +17,5 @@ Each L2VPN instance must have one of the following type associated with it: * MPLS-EVPN * PBB-EVPN -!!!note - Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add 2 terminations to a given L2VPN. +!!! note + Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add two terminations to a given L2VPN. diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md index cc1843639..e7c6a6810 100644 --- a/docs/models/ipam/l2vpntermination.md +++ b/docs/models/ipam/l2vpntermination.md @@ -1,15 +1,15 @@ # L2VPN Termination -A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPN's may only have 2 termination points (point-to-point) while others may have many terminations (multipoint). +A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPNs may only have 2 termination points (point-to-point) while others may have many terminations (multipoint). Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN. -The following types of L2VPN's are considered point-to-point: +The following types of L2VPNs are considered point-to-point: * VPWS * EPL * EP-LAN * EP-TREE -!!!note - Choosing any of the above types of L2VPN's will result in only being able to add 2 terminations to a given L2VPN. +!!! note + Choosing any of the above types will result in only being able to add 2 terminations to a given L2VPN. diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 8005c0afe..cee38fb18 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -4,7 +4,7 @@ from django.db.models import Q from dcim.filtersets import CabledObjectFilterSet from dcim.models import Region, Site, SiteGroup from ipam.models import ASN -from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet +from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 1a66312da..1fe21ed4b 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1031,8 +1031,8 @@ class InterfacePoEModeChoices(ChoiceSet): MODE_PSE = 'pse' CHOICES = ( - (MODE_PD, 'Powered device (PD)'), - (MODE_PSE, 'Power sourcing equipment (PSE)'), + (MODE_PD, 'PD'), + (MODE_PSE, 'PSE'), ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index b4ab226ae..6d51302d3 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1073,13 +1073,15 @@ class InterfaceBulkEditForm( choices=add_blank_choice(InterfacePoEModeChoices), required=False, initial='', - widget=StaticSelect() + widget=StaticSelect(), + label='PoE mode' ) poe_type = forms.ChoiceField( choices=add_blank_choice(InterfacePoETypeChoices), required=False, initial='', - widget=StaticSelect() + widget=StaticSelect(), + label='PoE type' ) mark_connected = forms.NullBooleanField( required=False, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9645efdbf..8f62b0626 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -227,6 +227,10 @@ class PathEndpoint(models.Model): # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s)) return list(zip(*[iter(path)] * 3)) + @property + def path(self): + return self._path + @cached_property def connected_endpoints(self): """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 43b84974b..6075cb5a0 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,3 +1,4 @@ +import decimal from collections import OrderedDict import yaml @@ -279,6 +280,12 @@ class DeviceType(NetBoxModel): def clean(self): super().clean() + # U height must be divisible by 0.5 + if self.u_height % decimal.Decimal(0.5): + raise ValidationError({ + 'u_height': "U height must be in increments of 0.5 rack units." + }) + # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have # room to expand within their racks. This validation will impose a very high performance penalty when there are # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. @@ -811,7 +818,11 @@ class Device(NetBoxModel, ConfigContextModel): 'position': "Cannot select a rack position without assigning a rack.", }) - # Validate position/face combination + # Validate rack position and face + if self.position and self.position % decimal.Decimal(0.5): + raise ValidationError({ + 'position': "Position must be in increments of 0.5 rack units." + }) if self.position and not self.face: raise ValidationError({ 'face': "Must specify rack face when defining rack position.", diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 920bd662f..f228c416b 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -260,13 +260,14 @@ class RackElevationSVG: ) for ru in range(0, self.rack.u_height): + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height text_coords = ( x_offset + self.unit_width / 2, y_offset + self.unit_height / 2 ) - link = Hyperlink(href=url_string.format(ru), target='_blank') + link = Hyperlink(href=url_string.format(unit), target='_blank') link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) link.add(Text('add device', insert=text_coords, class_='add-device')) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index e452badea..5dc2aa611 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -125,6 +125,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable): site = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 4cea7989b..e5f1df5ae 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -41,7 +41,11 @@ Color -   + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 3caae49c3..ae7b55316 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -41,7 +41,11 @@ Color -   + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 0c2f43de8..946467e31 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -5,7 +5,13 @@ {% block breadcrumbs %} {{ block.super }} - + {% endblock %} {% block extra_controls %} diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 018b50c99..a60d15281 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -166,7 +166,8 @@ class ClusterRemoveDevicesForm(ConfirmationForm): class VirtualMachineForm(TenancyForm, NetBoxModelForm): site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + required=False ) cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), @@ -178,6 +179,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), + required=False, query_params={ 'site_id': '$site', 'group_id': '$cluster_group', @@ -188,7 +190,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, query_params={ 'cluster_id': '$cluster' - } + }, + help_text="Optionally pin this VM to a specific host device within the cluster" ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), @@ -208,7 +211,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('site', 'cluster_group', 'cluster', 'device')), + ('Site/Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), From 43a4a9c86d69d273e8564fca6b72d2b93b27f26c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Jul 2022 16:29:43 -0400 Subject: [PATCH 158/245] Complete release notes --- docs/release-notes/version-3.3.md | 46 +++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ac499f806..3d514ea69 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -31,17 +31,52 @@ ### New Features -#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) +#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102)) -#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) +When creating a cable in NetBox, each end can now be attached to multiple objects. This allows accurate modeling of duplex fiber connections to individual termination ports and breakout cables, as examples. (Note that all terminations attached to one end of a cable must be the same object type, but do not need to connect to the same parent object.) Additionally, cable terminations can now be modified without needing to delete and recreate the cable. #### L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157)) +NetBox can now model a variety of L2 VPN technologies, including VXLAN, VPLS, and others. Each L2VPN can be terminated to multiple device or virtual machine interfaces and/or VLANs to track connectivity across an overlay. Similarly to VRFs, each L2VPN can also have import and export route targets associated with it. + +#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) + +Two new fields have been added to the device interface model to track power over Ethernet (PoE) capabilities: + +* **PoE mode**: Power supplying equipment (PSE) or powered device (PD) +* **PoE type**: Applicable IEEE standard or other power type + +#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) + +Device type height can now be specified in 0.5U increments, allowing for the creation of half-height devices. Additionally, a device can be installed at the half-unit mark within a rack (e.g. U2.5). For example, two half-height devices positioned in sequence will consume a single rack unit; two consecutive 1.5U devices will consume 3U of space. + #### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) +API tokens can now be restricted to use by certain client IP addresses or networks. For example, an API token with its `allowed_ips` list set to `[192.0.2.0/24]` will only permit authentication from API clients within that network; requests from other sources will fail authentication. This can be very useful for restricting the use of a token to specific clients. + #### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) -#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102)) +NetBox's permission constraints have been expanded to support referencing the current user associated with a request using the special `$user` token. As an example, this enables an administrator to efficiently grant each user to edit his or her own journal entries, but not those created by other users. + +```json +{ + "created_by": "$user" +} +``` + +#### Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495)) + +A `group_name` field has been added to the custom field model to enable organizing related custom fields by group. Similarly to custom links, custom links which have been assigned to a common group will be rendered within that group when viewing an object in the UI. (Custom field grouping has no effect on API operation.) + +#### Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166)) + +The behavior of each custom field within the NetBox UI can now be controlled individually by toggling its UI visibility. Three settings are available: + +* **Read/write**: The custom field is included when viewing and editing objects (default). +* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.) +* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users. + +Custom field UI visibility has no impact on API operation. ### Enhancements @@ -54,11 +89,9 @@ * [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster -* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results * [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions -* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields * [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location @@ -78,6 +111,7 @@ ### REST API Changes +* List results can now be ordered by field, by appending `?ordering={fieldname}` to the query. Multiple fields can be specified by separating the field names with a comma, e.g. `?ordering=site,name`. To invert the ordering, prepend a hyphen to the field name, e.g. `?ordering=-name`. * Added the following endpoints: * `/api/dcim/cable-terminations/` * `/api/ipam/l2vpns/` @@ -173,7 +207,7 @@ * virtualization.VirtualMachine * The `site` field is now directly writable (rather than being inferred from the assigned cluster) * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned. - * Added the `device` field + * Added the optional `device` field * Added the `l2vpn_termination` read-only field wireless.WirelessLAN * Added `tenant` field From fb2bfe23375c5ffa718424ae8ae0c751eabafae0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Jul 2022 15:12:53 -0400 Subject: [PATCH 159/245] Introduce GenericObjectSerializer --- netbox/netbox/api/serializers/__init__.py | 1 + netbox/netbox/api/serializers/generic.py | 31 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 netbox/netbox/api/serializers/generic.py diff --git a/netbox/netbox/api/serializers/__init__.py b/netbox/netbox/api/serializers/__init__.py index adc556549..0ec3ab5f3 100644 --- a/netbox/netbox/api/serializers/__init__.py +++ b/netbox/netbox/api/serializers/__init__.py @@ -2,6 +2,7 @@ from rest_framework import serializers from .base import * from .features import * +from .generic import * from .nested import * diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py new file mode 100644 index 000000000..81ec338ff --- /dev/null +++ b/netbox/netbox/api/serializers/generic.py @@ -0,0 +1,31 @@ +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers + +from netbox.api import ContentTypeField +from utilities.utils import content_type_identifier + +__all__ = ( + 'GenericObjectSerializer', +) + + +class GenericObjectSerializer(serializers.Serializer): + """ + Minimal representation of some generic object identified by ContentType and PK. + """ + object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + object_id = serializers.IntegerField() + + def to_internal_value(self, data): + data = super().to_internal_value(data) + model = data['object_type'].model_class() + return model.objects.get(pk=data['object_id']) + + def to_representation(self, instance): + ct = ContentType.objects.get_for_model(instance) + return { + 'object_type': content_type_identifier(ct), + 'object_id': instance.pk, + } From 0b86326435fe6ea07ef376a81ff6fb592906fafc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Jul 2022 15:35:37 -0400 Subject: [PATCH 160/245] #9102: Enable creating terminations in conjunction with cables via REST API --- docs/release-notes/version-3.3.md | 6 +- netbox/dcim/api/serializers.py | 50 ++------------- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/connections.py | 4 +- netbox/dcim/models/cables.py | 103 +++++++++++++++++------------- netbox/dcim/signals.py | 2 +- netbox/dcim/tests/test_api.py | 48 ++++++++++---- netbox/dcim/tests/test_views.py | 16 ++++- netbox/templates/dcim/cable.html | 4 +- 9 files changed, 123 insertions(+), 112 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 3d514ea69..7a093a35b 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,12 +6,12 @@ * Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units. * The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). -* Several fields on the cable API serializers have been altered to support multiple-object cable terminations: +* Several fields on the cable API serializers have been altered or removed to support multiple-object cable terminations: | Old Name | Old Type | New Name | New Type | |----------------------|----------|-----------------------|----------| -| `termination_a_type` | string | `a_terminations_type` | string | -| `termination_b_type` | string | `b_terminations_type` | string | +| `termination_a_type` | string | _Removed_ | - | +| `termination_b_type` | string | _Removed_ | - | | `termination_a_id` | integer | _Removed_ | - | | `termination_b_id` | integer | _Removed_ | - | | `termination_a` | object | `a_terminations` | list | diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 08edd2820..66d28c3fb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -15,7 +15,8 @@ from ipam.api.nested_serializers import ( from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( - NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, + GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, + WritableNestedSerializer, ) from netbox.config import ConfigItem from tenancy.api.nested_serializers import NestedTenantSerializer @@ -994,10 +995,8 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer): class CableSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') - a_terminations_type = serializers.SerializerMethodField(read_only=True) - b_terminations_type = serializers.SerializerMethodField(read_only=True) - a_terminations = serializers.SerializerMethodField(read_only=True) - b_terminations = serializers.SerializerMethodField(read_only=True) + a_terminations = GenericObjectSerializer(many=True, required=False) + b_terminations = GenericObjectSerializer(many=True, required=False) status = ChoiceField(choices=LinkStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) @@ -1005,47 +1004,10 @@ class CableSerializer(NetBoxModelSerializer): class Meta: model = Cable fields = [ - 'id', 'url', 'display', 'type', 'a_terminations_type', 'a_terminations', 'b_terminations_type', - 'b_terminations', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', + 'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated', ] - def _get_terminations_type(self, obj, side): - assert side in CableEndChoices.values() - terms = getattr(obj, f'get_{side.lower()}_terminations')() - if terms: - ct = ContentType.objects.get_for_model(terms[0]) - return f"{ct.app_label}.{ct.model}" - - def _get_terminations(self, obj, side): - assert side in CableEndChoices.values() - terms = getattr(obj, f'get_{side.lower()}_terminations')() - if not terms: - return [] - - termination_type = ContentType.objects.get_for_model(terms[0]) - serializer = get_serializer_for_model(termination_type.model_class(), prefix='Nested') - context = {'request': self.context['request']} - data = serializer(terms, context=context, many=True).data - - return data - - @swagger_serializer_method(serializer_or_field=serializers.CharField) - def get_a_terminations_type(self, obj): - return self._get_terminations_type(obj, CableEndChoices.SIDE_A) - - @swagger_serializer_method(serializer_or_field=serializers.CharField) - def get_b_terminations_type(self, obj): - return self._get_terminations_type(obj, CableEndChoices.SIDE_B) - - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_a_terminations(self, obj): - return self._get_terminations(obj, CableEndChoices.SIDE_A) - - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_b_terminations(self, obj): - return self._get_terminations(obj, CableEndChoices.SIDE_B) - class TracedCableSerializer(serializers.ModelSerializer): """ diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index d6ec0f6f4..f0fd9bf86 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -955,7 +955,7 @@ class CableCSVForm(NetBoxModelCSVForm): except ObjectDoesNotExist: raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") - setattr(self.instance, f'termination_{side}', termination_object) + setattr(self.instance, f'{side}_terminations', [termination_object]) return termination_object def clean_side_a_name(self): diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index f7bfa6431..1b393ec6e 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -157,8 +157,8 @@ def get_cable_form(a_type, b_type): if self.instance and self.instance.pk: # Initialize A/B terminations when modifying an existing Cable instance - self.initial['a_terminations'] = self.instance.get_a_terminations() - self.initial['b_terminations'] = self.instance.get_b_terminations() + self.initial['a_terminations'] = self.instance.a_terminations + self.initial['b_terminations'] = self.instance.b_terminations def save(self, *args, **kwargs): diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 551521c26..b35864aa0 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -93,11 +93,12 @@ class Cable(NetBoxModel): # Cache the original status so we can check later if it's been changed self._orig_status = self.status - # Assign any *new* CableTerminations for the instance. These will replace any existing - # terminations on save(). - if a_terminations is not None: + self._terminations_modified = False + + # Assign or retrieve A/B terminations + if a_terminations: self.a_terminations = a_terminations - if b_terminations is not None: + if b_terminations: self.b_terminations = b_terminations def __str__(self): @@ -107,6 +108,34 @@ class Cable(NetBoxModel): def get_absolute_url(self): return reverse('dcim:cable', args=[self.pk]) + @property + def a_terminations(self): + if hasattr(self, '_a_terminations'): + return self._a_terminations + # Query self.terminations.all() to leverage cached results + return [ + ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A + ] + + @a_terminations.setter + def a_terminations(self, value): + self._terminations_modified = True + self._a_terminations = value + + @property + def b_terminations(self): + if hasattr(self, '_b_terminations'): + return self._b_terminations + # Query self.terminations.all() to leverage cached results + return [ + ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B + ] + + @b_terminations.setter + def b_terminations(self, value): + self._terminations_modified = True + self._b_terminations = value + def clean(self): super().clean() @@ -116,30 +145,28 @@ class Cable(NetBoxModel): elif self.length is None: self.length_unit = '' - a_terminations = [ - CableTermination(cable=self, cable_end='A', termination=t) - for t in getattr(self, 'a_terminations', []) - ] - b_terminations = [ - CableTermination(cable=self, cable_end='B', termination=t) - for t in getattr(self, 'b_terminations', []) - ] + if self.pk is None and (not self.a_terminations or not self.b_terminations): + raise ValidationError("Must define A and B terminations when creating a new cable.") - # Check that all termination objects for either end are of the same type - for terms in (a_terminations, b_terminations): - if len(terms) > 1 and not all(t.termination_type == terms[0].termination_type for t in terms[1:]): - raise ValidationError("Cannot connect different termination types to same end of cable.") + if self._terminations_modified: - # Check that termination types are compatible - if a_terminations and b_terminations: - a_type = a_terminations[0].termination_type.model - b_type = b_terminations[0].termination_type.model - if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): - raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") + # Check that all termination objects for either end are of the same type + for terms in (self.a_terminations, self.b_terminations): + if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]): + raise ValidationError("Cannot connect different termination types to same end of cable.") - # Run clean() on any new CableTerminations - for cabletermination in [*a_terminations, *b_terminations]: - cabletermination.clean() + # Check that termination types are compatible + if self.a_terminations and self.b_terminations: + a_type = self.a_terminations[0]._meta.model_name + b_type = self.b_terminations[0]._meta.model_name + if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): + raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") + + # Run clean() on any new CableTerminations + for termination in self.a_terminations: + CableTermination(cable=self, cable_end='A', termination=termination).clean() + for termination in self.b_terminations: + CableTermination(cable=self, cable_end='B', termination=termination).clean() def save(self, *args, **kwargs): _created = self.pk is None @@ -160,23 +187,21 @@ class Cable(NetBoxModel): b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')} # Delete stale CableTerminations - if hasattr(self, 'a_terminations'): + if self._terminations_modified: for termination, ct in a_terminations.items(): - if termination not in self.a_terminations: + if termination.pk and termination not in self.a_terminations: ct.delete() - if hasattr(self, 'b_terminations'): for termination, ct in b_terminations.items(): - if termination not in self.b_terminations: + if termination.pk and termination not in self.b_terminations: ct.delete() # Save new CableTerminations (if any) - if hasattr(self, 'a_terminations'): + if self._terminations_modified: for termination in self.a_terminations: - if termination not in a_terminations: + if not termination.pk or termination not in a_terminations: CableTermination(cable=self, cable_end='A', termination=termination).save() - if hasattr(self, 'b_terminations'): for termination in self.b_terminations: - if termination not in b_terminations: + if not termination.pk or termination not in b_terminations: CableTermination(cable=self, cable_end='B', termination=termination).save() trace_paths.send(Cable, instance=self, created=_created) @@ -184,18 +209,6 @@ class Cable(NetBoxModel): def get_status_color(self): return LinkStatusChoices.colors.get(self.status) - def get_a_terminations(self): - # Query self.terminations.all() to leverage cached results - return [ - ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A - ] - - def get_b_terminations(self): - # Query self.terminations.all() to leverage cached results - return [ - ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B - ] - class CableTermination(models.Model): """ diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 7cfdc823d..2293f8840 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -79,7 +79,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): return # Update cable paths if new terminations have been set - if hasattr(instance, 'a_terminations') or hasattr(instance, 'b_terminations'): + if instance._terminations_modified: a_terminations = [] b_terminations = [] for t in instance.terminations.all(): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1c6f45596..a78a98ae5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,6 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF +from netbox.api.serializers import GenericObjectSerializer from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices @@ -1864,6 +1865,17 @@ class CableTest(APIViewTestCases.APIViewTestCase): # TODO: Allow updating cable terminations test_update_object = None + def model_to_dict(self, *args, **kwargs): + data = super().model_to_dict(*args, **kwargs) + + # Serialize termination objects + if 'a_terminations' in data: + data['a_terminations'] = GenericObjectSerializer(data['a_terminations'], many=True).data + if 'b_terminations' in data: + data['b_terminations'] = GenericObjectSerializer(data['b_terminations'], many=True).data + + return data + @classmethod def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') @@ -1893,24 +1905,36 @@ class CableTest(APIViewTestCases.APIViewTestCase): cls.create_data = [ { - 'a_terminations_type': 'dcim.interface', - 'a_terminations': [interfaces[4].pk], - 'b_terminations_type': 'dcim.interface', - 'b_terminations': [interfaces[14].pk], + 'a_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[4].pk, + }], + 'b_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[14].pk, + }], 'label': 'Cable 4', }, { - 'a_terminations_type': 'dcim.interface', - 'a_terminations': [interfaces[5].pk], - 'b_terminations_type': 'dcim.interface', - 'b_terminations': [interfaces[15].pk], + 'a_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[5].pk, + }], + 'b_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[15].pk, + }], 'label': 'Cable 5', }, { - 'a_terminations_type': 'dcim.interface', - 'a_terminations': [interfaces[6].pk], - 'b_terminations_type': 'dcim.interface', - 'b_terminations': [interfaces[16].pk], + 'a_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[6].pk, + }], + 'b_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[16].pk, + }], 'label': 'Cable 6', }, ] diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c6a531a31..a25267166 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -12,6 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF +from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -2640,8 +2641,8 @@ class CableTestCase( cls.form_data = { # TODO: Revisit this limitation # Changing terminations not supported when editing an existing Cable - 'a_terminations': interfaces[0].pk, - 'b_terminations': interfaces[3].pk, + 'a_terminations': [interfaces[0].pk], + 'b_terminations': [interfaces[3].pk], 'type': CableTypeChoices.TYPE_CAT6, 'status': LinkStatusChoices.STATUS_PLANNED, 'label': 'Label', @@ -2667,6 +2668,17 @@ class CableTestCase( 'length_unit': CableLengthUnitChoices.UNIT_METER, } + def model_to_dict(self, *args, **kwargs): + data = super().model_to_dict(*args, **kwargs) + + # Serialize termination objects + if 'a_terminations' in data: + data['a_terminations'] = [obj.pk for obj in data['a_terminations']] + if 'b_terminations' in data: + data['b_terminations'] = [obj.pk for obj in data['b_terminations']] + + return data + class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index f557792c1..e032d7034 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -63,13 +63,13 @@
    Termination A
    - {% include 'dcim/inc/cable_termination.html' with terminations=object.get_a_terminations %} + {% include 'dcim/inc/cable_termination.html' with terminations=object.a_terminations %}
    Termination B
    - {% include 'dcim/inc/cable_termination.html' with terminations=object.get_b_terminations %} + {% include 'dcim/inc/cable_termination.html' with terminations=object.b_terminations %}
    {% plugin_right_page object %} From 3eb6b6c07fc1e666fa1148981200cec615b0a445 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Jul 2022 16:18:55 -0400 Subject: [PATCH 161/245] Clean up core API imports --- netbox/circuits/api/nested_serializers.py | 2 +- netbox/circuits/api/serializers.py | 4 ++-- netbox/circuits/api/urls.py | 2 +- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/api/urls.py | 2 +- netbox/extras/api/nested_serializers.py | 4 ++-- netbox/extras/api/serializers.py | 2 +- netbox/extras/api/urls.py | 2 +- netbox/ipam/api/nested_serializers.py | 2 +- netbox/ipam/api/serializers.py | 2 +- netbox/ipam/api/urls.py | 3 +-- netbox/netbox/api/__init__.py | 15 --------------- netbox/netbox/api/metadata.py | 2 +- netbox/tenancy/api/nested_serializers.py | 2 +- netbox/tenancy/api/serializers.py | 2 +- netbox/tenancy/api/urls.py | 2 +- netbox/users/api/nested_serializers.py | 3 ++- netbox/users/api/serializers.py | 3 ++- netbox/users/api/urls.py | 2 +- netbox/utilities/custom_inspectors.py | 3 ++- netbox/virtualization/api/nested_serializers.py | 2 +- netbox/virtualization/api/serializers.py | 2 +- netbox/virtualization/api/urls.py | 2 +- netbox/wireless/api/nested_serializers.py | 2 +- netbox/wireless/api/serializers.py | 2 +- netbox/wireless/api/urls.py | 2 +- 26 files changed, 30 insertions(+), 43 deletions(-) diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 6f7cb4f21..8fc1bfaf7 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import * -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 6b09cd531..c1d856f39 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,11 +2,11 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices from circuits.models import * -from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import NestedSiteSerializer from dcim.api.serializers import CabledObjectSerializer from ipam.models import ASN from ipam.api.nested_serializers import NestedASNSerializer -from netbox.api import ChoiceField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 616adfaa4..9d75009d5 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 66d28c3fb..cc5c87a8a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,7 +13,7 @@ from ipam.api.nested_serializers import ( NestedVRFSerializer, ) from ipam.models import ASN, VLAN -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index e73678f71..47bbfd525 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 4acde31ab..44dfe7cbc 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers from extras import choices, models -from netbox.api import ChoiceField, WritableNestedSerializer -from netbox.api.serializers import NestedTagSerializer +from netbox.api.fields import ChoiceField +from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer from users.api.nested_serializers import NestedUserSerializer __all__ = [ diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 988c3bf7b..0688f6d76 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -12,8 +12,8 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from extras.choices import * from extras.models import * from extras.utils import FeatureQuery -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.exceptions import SerializerNotFound +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index dd6a5aeff..bcad6b77c 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index e74d60fb2..7809e84f8 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from ipam import models from ipam.models.l2vpn import L2VPNTermination, L2VPN -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9cde08374..32fa4e6af 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -8,7 +8,7 @@ from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerial from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.models import * -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 20e31f4d4..1e077c087 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,7 +1,6 @@ from django.urls import path -from netbox.api import NetBoxRouter -from ipam.models import IPRange, Prefix +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 231ab55e6..e69de29bb 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,15 +0,0 @@ -from .fields import * -from .routers import NetBoxRouter -from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer - - -__all__ = ( - 'BulkOperationSerializer', - 'ChoiceField', - 'ContentTypeField', - 'IPNetworkSerializer', - 'NetBoxRouter', - 'SerializedPKRelatedField', - 'ValidatedModelSerializer', - 'WritableNestedSerializer', -) diff --git a/netbox/netbox/api/metadata.py b/netbox/netbox/api/metadata.py index bc4ecf871..dff1474d9 100644 --- a/netbox/netbox/api/metadata.py +++ b/netbox/netbox/api/metadata.py @@ -5,7 +5,7 @@ from rest_framework import exceptions from rest_framework.metadata import SimpleMetadata from rest_framework.request import clone_request -from netbox.api import ContentTypeField +from netbox.api.fields import ContentTypeField class BulkOperationMetadata(SimpleMetadata): diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 00ac6ff84..2f95eca8c 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer from tenancy.models import * __all__ = [ diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index a2286efed..b5cce741a 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from netbox.api import ChoiceField, ContentTypeField +from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import * diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 7dbe59ea4..18ea98241 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 51e0c5b26..e9e730cc4 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -2,7 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField, WritableNestedSerializer +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import WritableNestedSerializer from users.models import ObjectPermission, Token __all__ = [ diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index d05f6c7da..1ec3528f7 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,7 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField +from netbox.api.serializers import ValidatedModelSerializer from users.models import ObjectPermission, Token from .nested_serializers import * diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index f46cc1680..599d0bb61 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index b110a9123..1a5ede23f 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -6,7 +6,8 @@ from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField from extras.api.customfields import CustomFieldsDataField -from netbox.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer +from netbox.api.fields import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import WritableNestedSerializer class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 16e10b5fd..07a9f5d13 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface __all__ = [ diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index c5816dca8..903d89a07 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -9,7 +9,7 @@ from ipam.api.nested_serializers import ( NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer, ) from ipam.models import VLAN -from netbox.api import ChoiceField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 07b20bfd7..2ceeb8ce6 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index e9a840bfc..0e8404266 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer from wireless.models import * __all__ = ( diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 49d512e51..d65511765 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from dcim.choices import LinkStatusChoices from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer -from netbox.api import ChoiceField +from netbox.api.fields import ChoiceField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from wireless.choices import * diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 47799bd3a..5375172eb 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views From be00d1f3c6cb207cc9b3788c04f0356e1875a1cd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Jul 2022 08:36:16 -0400 Subject: [PATCH 162/245] Fix erroneous import --- netbox/netbox/api/serializers/generic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py index 81ec338ff..8b4069c98 100644 --- a/netbox/netbox/api/serializers/generic.py +++ b/netbox/netbox/api/serializers/generic.py @@ -1,7 +1,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField +from netbox.api.fields import ContentTypeField from utilities.utils import content_type_identifier __all__ = ( From 0615252e1555edc1f06dfa4ec057573573a4c826 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Jul 2022 10:33:20 -0400 Subject: [PATCH 163/245] Add progress output to cable migration --- .../dcim/migrations/0158_populate_cable_terminations.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/dcim/migrations/0158_populate_cable_terminations.py b/netbox/dcim/migrations/0158_populate_cable_terminations.py index 82f8cd359..5836d24be 100644 --- a/netbox/dcim/migrations/0158_populate_cable_terminations.py +++ b/netbox/dcim/migrations/0158_populate_cable_terminations.py @@ -1,3 +1,5 @@ +import sys + from django.db import migrations @@ -42,6 +44,7 @@ def populate_cable_terminations(apps, schema_editor): # Queue CableTerminations to be created cable_terminations = [] + cable_count = cables.count() for i, cable in enumerate(cables, start=1): for cable_end in ('a', 'b'): # We must manually instantiate the termination object, because GFK fields are not @@ -58,6 +61,12 @@ def populate_cable_terminations(apps, schema_editor): **cache_related_objects(termination) )) + # Output progress occasionally + if 'test' not in sys.argv and not i % 100: + progress = float(i) * 100 / cable_count + sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)") + sys.stdout.flush() + # Bulk create the termination objects CableTermination.objects.bulk_create(cable_terminations, batch_size=100) From 29c81a788ff2d3121a9f80e55f8b021b5fca59c3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Jul 2022 10:37:27 -0400 Subject: [PATCH 164/245] Clean up connection tables --- netbox/dcim/tables/devices.py | 6 +++--- netbox/dcim/tables/template_code.py | 24 +++++------------------- netbox/templates/dcim/cable_trace.html | 14 +++++++------- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a6c0c0ecc..c1515a15f 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -274,17 +274,17 @@ class CableTerminationTable(NetBoxTable): verbose_name='Cable Color' ) link_peer = columns.TemplateColumn( - accessor='_link_peer', + accessor='link_peers', template_code=LINKTERMINATION, orderable=False, - verbose_name='Link Peer' + verbose_name='Link Peers' ) mark_connected = columns.BooleanColumn() class PathEndpointTable(CableTerminationTable): connection = columns.TemplateColumn( - accessor='_path__last_node', + accessor='_path__destinations', template_code=LINKTERMINATION, verbose_name='Connection', orderable=False diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index a07186973..90befe0a4 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,11 +1,9 @@ LINKTERMINATION = """ -{% if value %} - {% if value.parent_object %} - {{ value.parent_object }} - - {% endif %} - {{ value }} -{% endif %} +{% for termination in value %} + {{ termination }}{% if not forloop.last %},{% endif %} +{% empty %} + {{ ''|placeholder }} +{% endfor %} """ CABLE_LENGTH = """ @@ -13,18 +11,6 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} """ -# CABLE_TERMINATION_PARENT = """ -# {% with value.0 as termination %} -# {% if termination.device %} -# {{ termination.device }} -# {% elif termination.circuit %} -# {{ termination.circuit }} -# {% elif termination.power_panel %} -# {{ termination.power_panel }} -# {% endif %} -# {% endwith %} -# """ - DEVICE_LINK = """ {{ record.name|default:'Unnamed device' }} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index ac0481925..abe13b236 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -10,10 +10,12 @@
    {% if path %}
    {% with traced_path=path.origin.trace %} @@ -80,18 +82,16 @@ {% for cablepath in related_paths %} - - {{ cablepath.origin.parent_object }} / {{ cablepath.origin }} - + {{ cablepath.origins|join:", " }} - {% if cablepath.destination %} - {{ cablepath.destination }} ({{ cablepath.destination.parent_object }}) + {% if cablepath.destinations %} + {{ cablepath.destinations|join:", " }} {% else %} Incomplete {% endif %} - + {{ cablepath.segment_count }} From 15395a56e736e740757d4a2dec558ea13c862e82 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Jul 2022 11:02:00 -0400 Subject: [PATCH 165/245] Move L2VPN into new menu group --- netbox/netbox/navigation_menu.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 513cf4d9e..a495f17c9 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -174,7 +174,7 @@ DEVICES_MENU = Menu( CONNECTIONS_MENU = Menu( label='Connections', - icon_class='mdi mdi-ethernet', + icon_class='mdi mdi-connection', groups=( MenuGroup( label='Connections', @@ -260,13 +260,6 @@ IPAM_MENU = Menu( get_model_item('ipam', 'vlangroup', 'VLAN Groups'), ), ), - MenuGroup( - label='L2VPNs', - items=( - get_model_item('ipam', 'l2vpn', 'L2VPNs'), - get_model_item('ipam', 'l2vpntermination', 'Terminations'), - ), - ), MenuGroup( label='Other', items=( @@ -278,6 +271,20 @@ IPAM_MENU = Menu( ), ) +OVERLAY_MENU = Menu( + label='Overlay', + icon_class='mdi mdi-graph-outline', + groups=( + MenuGroup( + label='L2VPNs', + items=( + get_model_item('ipam', 'l2vpn', 'L2VPNs'), + get_model_item('ipam', 'l2vpntermination', 'Terminations'), + ), + ), + ), +) + VIRTUALIZATION_MENU = Menu( label='Virtualization', icon_class='mdi mdi-monitor', @@ -387,6 +394,7 @@ MENUS = [ CONNECTIONS_MENU, WIRELESS_MENU, IPAM_MENU, + OVERLAY_MENU, VIRTUALIZATION_MENU, CIRCUITS_MENU, POWER_MENU, From 868e94fb730e24e174abba13a77cdef32c619e6e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Jul 2022 12:04:53 -0400 Subject: [PATCH 166/245] Release v3.3-beta1 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 3 +-- docs/release-notes/version-3.3.md | 2 +- mkdocs.yml | 1 + .../extras/migrations/0076_tag_slug_unicode.py | 18 ++++++++++++++++++ netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 8 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 netbox/extras/migrations/0076_tag_slug_unicode.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 78231890b..88fbb1df9 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.2.6 + placeholder: v3.3-beta1 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 71d45092c..1035c02fb 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.2.6 + placeholder: v3.3-beta1 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index efeede487..59d4b8255 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -52,8 +52,7 @@ django-tables2 # User-defined tags for objects # https://github.com/alex/django-taggit -# Will evaluate v3.0 during NetBox v3.3 beta -django-taggit>=2.1.0,<3.0 +django-taggit # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 7a093a35b..5fc11960e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3.0 (FUTURE) +## v3.3-beta1 (2022-07-14) ### Breaking Changes diff --git a/mkdocs.yml b/mkdocs.yml index 5e9f4b842..34c65ed01 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -152,6 +152,7 @@ nav: - Release Checklist: 'development/release-checklist.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.3: 'release-notes/version-3.3.md' - Version 3.2: 'release-notes/version-3.2.md' - Version 3.1: 'release-notes/version-3.1.md' - Version 3.0: 'release-notes/version-3.0.md' diff --git a/netbox/extras/migrations/0076_tag_slug_unicode.py b/netbox/extras/migrations/0076_tag_slug_unicode.py new file mode 100644 index 000000000..3f4922963 --- /dev/null +++ b/netbox/extras/migrations/0076_tag_slug_unicode.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.6 on 2022-07-14 15:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0075_configcontext_locations'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='slug', + field=models.SlugField(allow_unicode=True, max_length=100, unique=True), + ), + ] diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b776650dc..e8d414b44 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.0-dev' +VERSION = '3.3-beta1' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 570c59adf..4c8e5e5ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ django-redis==5.2.0 django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 -django-taggit==2.1.0 +django-taggit==3.0.0 django-timezone-field==5.0 djangorestframework==3.13.1 drf-yasg[validation]==1.20.0 @@ -26,10 +26,10 @@ netaddr==0.8.0 Pillow==9.2.0 psycopg2-binary==2.9.3 PyYAML==6.0 -sentry-sdk==1.7.0 +sentry-sdk==1.7.1 social-auth-app-django==5.0.0 social-auth-core==4.3.0 -svgwrite==1.4.2 +svgwrite==1.4.3 tablib==3.2.1 tzdata==2022.1 From e8dd952aa5dd908acfa3af47f918ab2f3e4b7c45 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Jul 2022 10:07:35 -0400 Subject: [PATCH 167/245] Fix migration progress output --- netbox/dcim/migrations/0158_populate_cable_terminations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/migrations/0158_populate_cable_terminations.py b/netbox/dcim/migrations/0158_populate_cable_terminations.py index 5836d24be..72d7f154a 100644 --- a/netbox/dcim/migrations/0158_populate_cable_terminations.py +++ b/netbox/dcim/migrations/0158_populate_cable_terminations.py @@ -64,6 +64,8 @@ def populate_cable_terminations(apps, schema_editor): # Output progress occasionally if 'test' not in sys.argv and not i % 100: progress = float(i) * 100 / cable_count + if i == 100: + print('') sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)") sys.stdout.flush() From 024e7d86510adcbe8a948640c052fb1c4059223a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Jul 2022 10:19:56 -0400 Subject: [PATCH 168/245] Fixes #9728: Fix validation when assigning a virtual machine to a device --- docs/release-notes/version-3.3.md | 6 +++++- netbox/virtualization/forms/models.py | 3 ++- netbox/virtualization/models.py | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 5fc11960e..b0bcaaa63 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3-beta1 (2022-07-14) +## v3.3.0 (FUTURE) ### Breaking Changes @@ -96,6 +96,10 @@ Custom field UI visibility has no impact on API operation. * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location +### Bug Fixes + +* [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device + ### Plugins API * [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index a60d15281..723c19332 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -189,7 +189,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): queryset=Device.objects.all(), required=False, query_params={ - 'cluster_id': '$cluster' + 'cluster_id': '$cluster', + 'site_id': '$site', }, help_text="Optionally pin this VM to a specific host device within the cluster" ) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 98321976f..f07b176e7 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -349,6 +349,10 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): }) # Validate assigned cluster device + if self.device and not self.cluster: + raise ValidationError({ + 'device': f'Must specify a cluster when assigning a host device.' + }) if self.device and self.device not in self.cluster.devices.all(): raise ValidationError({ 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).' From 587a34442a822e99357ee92daa9b5f43f3ce9ef6 Mon Sep 17 00:00:00 2001 From: Brian Candler Date: Fri, 15 Jul 2022 17:10:15 +0100 Subject: [PATCH 169/245] Documentation: distinguish release and git upgrade processes Fixes #9743 --- docs/installation/upgrading.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 014dffaf8..deeec883a 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -18,6 +18,21 @@ NetBox v3.0 and later require the following: As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. +!!! warning + Use the same method as you used to install Netbox originally + +If you are not sure how Netbox was installed originally, check with this +command: + +``` +ls -ld /opt/netbox /opt/netbox/.git +``` + +If Netbox was installed from a release package, then `/opt/netbox` will be a +symlink pointing to the current version, and `/opt/netbox/.git` will not +exist. If it was installed from git, then `/opt/netbox` and +`/opt/netbox/.git` will both exist as normal directories. + ### Option A: Download a Release Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`. From 68b87dd668dc1f497bd2cb27dc79b4e251d8626a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Jul 2022 13:27:20 -0400 Subject: [PATCH 170/245] Fixes #9729: Fix ordering of content type creation to ensure compatability with demo data --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/migrations/0160_populate_cable_ends.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index b0bcaaa63..5d4a6ccfb 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -99,6 +99,7 @@ Custom field UI visibility has no impact on API operation. ### Bug Fixes * [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device +* [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data ### Plugins API diff --git a/netbox/dcim/migrations/0160_populate_cable_ends.py b/netbox/dcim/migrations/0160_populate_cable_ends.py index 0dac81df3..53e042abc 100644 --- a/netbox/dcim/migrations/0160_populate_cable_ends.py +++ b/netbox/dcim/migrations/0160_populate_cable_ends.py @@ -3,7 +3,6 @@ from django.db import migrations def populate_cable_terminations(apps, schema_editor): Cable = apps.get_model('dcim', 'Cable') - ContentType = apps.get_model('contenttypes', 'ContentType') cable_termination_models = ( apps.get_model('dcim', 'ConsolePort'), @@ -18,12 +17,17 @@ def populate_cable_terminations(apps, schema_editor): ) for model in cable_termination_models: - ct = ContentType.objects.get_for_model(model) model.objects.filter( - id__in=Cable.objects.filter(termination_a_type=ct).values_list('termination_a_id', flat=True) + id__in=Cable.objects.filter( + termination_a_type__app_label=model._meta.app_label, + termination_a_type__model=model._meta.model_name + ).values_list('termination_a_id', flat=True) ).update(cable_end='A') model.objects.filter( - id__in=Cable.objects.filter(termination_b_type=ct).values_list('termination_b_id', flat=True) + id__in=Cable.objects.filter( + termination_b_type__app_label=model._meta.app_label, + termination_b_type__model=model._meta.model_name + ).values_list('termination_b_id', flat=True) ).update(cable_end='B') From f385a5fd5e87c3fd07cd804ad18239a7b54a72e2 Mon Sep 17 00:00:00 2001 From: Marek Zbroch Date: Sat, 16 Jul 2022 09:42:01 +0200 Subject: [PATCH 171/245] Typo fix in CableEditView --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b247c3a6d..12e070e70 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2850,7 +2850,7 @@ class CableEditView(generic.ObjectEditView): termination_a = obj.terminations.filter(cable_end='A').first() a_type = termination_a.termination._meta.model if termination_a else None termination_b = obj.terminations.filter(cable_end='B').first() - b_type = termination_b.termination._meta.model if termination_a else None + b_type = termination_b.termination._meta.model if termination_b else None self.form = forms.get_cable_form(a_type, b_type) return obj From 58b191b439462316229fdf3ecc5d3ac0461e3513 Mon Sep 17 00:00:00 2001 From: Marek Zbroch Date: Sun, 17 Jul 2022 09:28:58 +0200 Subject: [PATCH 172/245] Fix CableForm Validation for #9102 --- netbox/dcim/forms/connections.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 1b393ec6e..62247e3e3 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -160,12 +160,11 @@ def get_cable_form(a_type, b_type): self.initial['a_terminations'] = self.instance.a_terminations self.initial['b_terminations'] = self.instance.b_terminations - def save(self, *args, **kwargs): + def clean(self): + super().clean() # Set the A/B terminations on the Cable instance self.instance.a_terminations = self.cleaned_data['a_terminations'] self.instance.b_terminations = self.cleaned_data['b_terminations'] - return super().save(*args, **kwargs) - return _CableForm From 8a2276e79136d8bac7b212ce1ad7f014ffb0de41 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 18 Jul 2022 16:17:16 +0200 Subject: [PATCH 173/245] Use segment_count for segment count on trace view --- netbox/templates/dcim/cable_trace.html | 72 +++++++++++++------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index abe13b236..2611686f6 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -18,43 +18,41 @@
    - {% with traced_path=path.origin.trace %} - {% if path.is_split %} -

    Path split!

    -

    Select a node below to continue:

    -
      - {% for next_node in path.get_split_nodes %} - {% if next_node.cable %} -
    • - {{ next_node }} - (Cable {{ next_node.cable|linkify }}) -
    • - {% else %} -
    • {{ next_node }}
    • - {% endif %} - {% endfor %} -
    - {% else %} -

    Trace Completed

    - - - - - - - - - -
    Total segments{{ traced_path|length }}
    Total length - {% if total_length %} - {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters / - {{ total_length|meters_to_feet|floatformat:"-2" }} Feet - {% else %} - N/A - {% endif %} -
    - {% endif %} - {% endwith %} + {% if path.is_split %} +

    Path split!

    +

    Select a node below to continue:

    +
      + {% for next_node in path.get_split_nodes %} + {% if next_node.cable %} +
    • + {{ next_node }} + (Cable {{ next_node.cable|linkify }}) +
    • + {% else %} +
    • {{ next_node }}
    • + {% endif %} + {% endfor %} +
    + {% else %} +

    Trace Completed

    + + + + + + + + + +
    Total segments{{ path.segment_count }}
    Total length + {% if total_length %} + {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters / + {{ total_length|meters_to_feet|floatformat:"-2" }} Feet + {% else %} + N/A + {% endif %} +
    + {% endif %}
    {% else %}

    From b8da66bb55e562b7344812bf71a5165031b50cb8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Jul 2022 11:51:59 -0400 Subject: [PATCH 174/245] Fixes #9733: Handle split paths during trace when fanning out to front ports with differing cables --- docs/release-notes/version-3.3.md | 2 ++ netbox/dcim/models/cables.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 5d4a6ccfb..14952c2a5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -100,6 +100,8 @@ Custom field UI visibility has no impact on API operation. * [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device * [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data +* [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form +* [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables ### Plugins API diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index b35864aa0..e0a489f5b 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -463,6 +463,10 @@ class CablePath(models.Model): """ from circuits.models import CircuitTermination + # Ensure all originating terminations are attached to the same link + if len(terminations) > 1: + assert all(t.link == terminations[0].link for t in terminations[1:]) + path = [] position_stack = [] is_complete = False @@ -474,6 +478,12 @@ class CablePath(models.Model): # Terminations must all be of the same type assert all(isinstance(t, type(terminations[0])) 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: + is_split = True + break + # Step 1: Record the near-end termination object(s) path.append([ object_to_path_node(t) for t in terminations @@ -481,7 +491,6 @@ class CablePath(models.Model): # Step 2: Determine the attached link (Cable or WirelessLink), if any link = terminations[0].link - assert all(t.link == link for t in terminations[1:]) if link is None and len(path) == 1: # If this is the start of the path and no link exists, return None return None From 6d53788ea2ad0c946cac8f1ed15be690bf616205 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Jul 2022 13:05:12 -0400 Subject: [PATCH 175/245] Changelog for #9765 --- docs/release-notes/version-3.3.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 14952c2a5..4b3f1dcb2 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -102,6 +102,7 @@ Custom field UI visibility has no impact on API operation. * [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data * [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form * [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables +* [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view ### Plugins API From 0e18292e41f6feb77094ee0a9cd8c965071a5dd9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Jul 2022 13:09:45 -0400 Subject: [PATCH 176/245] Add summary release notes for v3.3 --- docs/release-notes/index.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index f8bb365be..8a6a86174 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,17 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 3.3](./version-3.3.md) (August 2022) + +* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102)) +* L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157)) +* PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) +* Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) +* Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) +* Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) +* Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495)) +* Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166)) + #### [Version 3.2](./version-3.2.md) (April 2022) * Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) From e92b7f8bb95ebf2018a0d57dad2fe88a69cecc85 Mon Sep 17 00:00:00 2001 From: Marek Zbroch Date: Wed, 20 Jul 2022 13:02:10 +0200 Subject: [PATCH 177/245] Typo fix in REARPORT_BUTTONS template code --- netbox/dcim/tables/template_code.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 90befe0a4..082df56df 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -343,7 +343,7 @@ REARPORT_BUTTONS = """
  • Console Port
  • Front Port
  • Rear Port
  • -
  • Circuit Termination
  • +
  • Circuit Termination
  • {% else %} From e2580ea469e0394d7e00a65d91aca51968a97ad1 Mon Sep 17 00:00:00 2001 From: Juho Ylikorpi Date: Thu, 21 Jul 2022 16:54:23 +0300 Subject: [PATCH 178/245] fixes #9818 --- netbox/dcim/forms/connections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 62247e3e3..7552c0c87 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -138,7 +138,7 @@ def get_cable_form(a_type, b_type): label='Side', disabled_indicator='_occupied', query_params={ - 'circuit_id': f'termination_{cable_end}_circuit', + 'circuit_id': f'$termination_{cable_end}_circuit', } ) From 12476036cd3dc5990d2a6c9973ed9ccc758c377d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Jul 2022 09:55:20 -0400 Subject: [PATCH 179/245] Changelog for #9794, #9818 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 4b3f1dcb2..ad9ce1a0e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -103,6 +103,8 @@ Custom field UI visibility has no impact on API operation. * [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form * [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables * [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view +* [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination +* [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination ### Plugins API From 2583abc39d4eafdca645bc18110140f1e350c7df Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Jul 2022 11:34:16 -0400 Subject: [PATCH 180/245] Fix null cable termination representation --- .../templates/dcim/inc/cable_termination.html | 118 +++++++++--------- 1 file changed, 61 insertions(+), 57 deletions(-) diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 9d1c43bdd..ced9bda50 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -1,58 +1,62 @@ {% load helpers %} - - {% if terminations.0.device %} - {# Device component #} - - - - - - - - - - - - - - - - - {% elif terminations.0.power_panel %} - {# Power feed #} - - - - - - - - - - - - - {% else %} - {# Circuit termination #} - - - - - - - - - {% endif %} -
    Site{{ terminations.0.device.site|linkify }}
    Rack{{ terminations.0.device.rack|linkify|placeholder }}
    Device{{ terminations.0.device|linkify }}
    {{ terminations.0|meta:"verbose_name"|capfirst }} - {% for term in terminations %} - {{ term|linkify }}{% if not forloop.last %},{% endif %} - {% endfor %} -
    Site{{ terminations.0.power_panel.site|linkify }}
    Power Panel{{ terminations.0.power_panel|linkify }}
    {{ terminations.0|meta:"verbose_name"|capfirst }} - {% for term in terminations %} - {{ term|linkify }}{% if not forloop.last %},{% endif %} - {% endfor %} -
    Provider{{ terminations.0.circuit.provider|linkify }}
    Circuit - {% for term in terminations %} - {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %} - {% endfor %} -
    +{% if terminations.0 %} + + {% if terminations.0.device %} + {# Device component #} + + + + + + + + + + + + + + + + + {% elif terminations.0.power_panel %} + {# Power feed #} + + + + + + + + + + + + + {% elif terminations.0.circuit %} + {# Circuit termination #} + + + + + + + + + {% endif %} +
    Site{{ terminations.0.device.site|linkify }}
    Rack{{ terminations.0.device.rack|linkify|placeholder }}
    Device{{ terminations.0.device|linkify }}
    {{ terminations.0|meta:"verbose_name"|capfirst }} + {% for term in terminations %} + {{ term|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
    Site{{ terminations.0.power_panel.site|linkify }}
    Power Panel{{ terminations.0.power_panel|linkify }}
    {{ terminations.0|meta:"verbose_name"|capfirst }} + {% for term in terminations %} + {{ term|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
    Provider{{ terminations.0.circuit.provider|linkify }}
    Circuit + {% for term in terminations %} + {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %} + {% endfor %} +
    +{% else %} + No termination +{% endif %} From 6f7289f93217c5d3499264f2cfe6e540ba17d8e9 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 26 Jul 2022 07:22:21 -0500 Subject: [PATCH 181/245] Fixes #9844 - Add dedicated `device_vlan` form field --- netbox/ipam/forms/models.py | 10 ++++++++-- netbox/templates/ipam/l2vpntermination_edit.html | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 415c952be..0a22cbc21 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -906,8 +906,9 @@ class L2VPNTerminationForm(NetBoxModelForm): label='L2VPN', fetch_trigger='open' ) - device = DynamicModelChoiceField( + device_vlan = DynamicModelChoiceField( queryset=Device.objects.all(), + label="Available on Device", required=False, query_params={} ) @@ -915,10 +916,15 @@ class L2VPNTerminationForm(NetBoxModelForm): queryset=VLAN.objects.all(), required=False, query_params={ - 'available_on_device': '$device' + 'available_on_device': '$device_vlan' }, label='VLAN' ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={} + ) interface = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index 7b4a9f50a..c66b8a3d1 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -32,7 +32,7 @@
    - {% render_field form.device %} + {% render_field form.device_vlan %} {% render_field form.vlan %}
    From 6d30c07dd0608c80e4f0a161d7f5ed129b9baa0e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 26 Jul 2022 07:29:18 -0500 Subject: [PATCH 182/245] Update changelog for #9844 --- docs/release-notes/version-3.3.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 4b3f1dcb2..85a2e1599 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -103,6 +103,7 @@ Custom field UI visibility has no impact on API operation. * [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form * [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables * [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view +* [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination ### Plugins API From d442f8fd60c88ffabcd6f75e438baa8ecca15f98 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Jul 2022 11:09:51 -0400 Subject: [PATCH 183/245] Fixes #9843: Fix rendering of custom field values (regression from #9647) --- docs/release-notes/version-3.3.md | 1 + .../templates/builtins/customfield_value.html | 20 +++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 8aa91f8f9..51d595123 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -105,6 +105,7 @@ Custom field UI visibility has no impact on API operation. * [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view * [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination * [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination +* [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647) * [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination ### Plugins API diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html index 8fedb03d5..ff93a5168 100644 --- a/netbox/utilities/templates/builtins/customfield_value.html +++ b/netbox/utilities/templates/builtins/customfield_value.html @@ -1,26 +1,26 @@ -{% if field.type == 'integer' and value is not None %} +{% if customfield.type == 'integer' and value is not None %} {{ value }} -{% elif field.type == 'longtext' and value %} +{% elif customfield.type == 'longtext' and value %} {{ value|markdown }} -{% elif field.type == 'boolean' and value == True %} +{% elif customfield.type == 'boolean' and value == True %} {% checkmark value true="True" %} -{% elif field.type == 'boolean' and value == False %} +{% elif customfield.type == 'boolean' and value == False %} {% checkmark value false="False" %} -{% elif field.type == 'url' and value %} +{% elif customfield.type == 'url' and value %} {{ value|truncatechars:70 }} -{% elif field.type == 'json' and value %} +{% elif customfield.type == 'json' and value %}
    {{ value|json }}
    -{% elif field.type == 'multiselect' and value %} +{% elif customfield.type == 'multiselect' and value %} {{ value|join:", " }} -{% elif field.type == 'object' and value %} +{% elif customfield.type == 'object' and value %} {{ value|linkify }} -{% elif field.type == 'multiobject' and value %} +{% elif customfield.type == 'multiobject' and value %} {% for object in value %} {{ object|linkify }}{% if not forloop.last %}
    {% endif %} {% endfor %} {% elif value %} {{ value }} -{% elif field.required %} +{% elif customfield.required %} Not defined {% else %} {{ ''|placeholder }} From 466931d2fbb5e9db6b3eb90d385119ae44b34886 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Jul 2022 12:41:51 -0400 Subject: [PATCH 184/245] Fixes #9829: Arrange custom fields by group when editing objects --- docs/release-notes/version-3.3.md | 1 + netbox/extras/forms/customfields.py | 4 +++ netbox/netbox/forms/base.py | 27 ++++++------------- .../templates/inc/panels/custom_fields.html | 2 +- .../form_helpers/render_custom_fields.html | 13 ++++++--- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 51d595123..7e8fd50e3 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -105,6 +105,7 @@ Custom field UI visibility has no impact on API operation. * [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view * [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination * [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination +* [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects * [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647) * [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 4cf8b5e0a..7574f4f2b 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -19,6 +19,7 @@ class CustomFieldsMixin: def __init__(self, *args, **kwargs): self.custom_fields = {} + self.custom_field_groups = {} super().__init__(*args, **kwargs) @@ -58,3 +59,6 @@ class CustomFieldsMixin: # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield + if customfield.group_name not in self.custom_field_groups: + self.custom_field_groups[customfield.group_name] = [] + self.custom_field_groups[customfield.group_name].append(field_name) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 0e232af1d..2676e4cde 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -94,30 +94,19 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields['pk'].queryset = self.model.objects.all() + self._extend_nullable_fields() + def _get_form_field(self, customfield): return customfield.to_form_field(set_initial=False, enforce_required=False) - def _append_customfield_fields(self): - """ - Append form fields for all CustomFields assigned to this object type. - """ - nullable_custom_fields = [] - for customfield in self._get_custom_fields(self._get_content_type()): - field_name = f'cf_{customfield.name}' - self.fields[field_name] = self._get_form_field(customfield) - - # Record non-required custom fields as nullable - if not customfield.required: - nullable_custom_fields.append(field_name) - - # Annotate the field in the list of CustomField form fields - self.custom_fields[field_name] = customfield - - # Annotate nullable custom fields (if any) on the form instance - if nullable_custom_fields: - self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) + def _extend_nullable_fields(self): + nullable_custom_fields = [ + name for name, customfield in self.custom_fields.items() if not customfield.required + ] + self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 90059447f..616b1c712 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -7,7 +7,7 @@
    {% for group_name, fields in custom_fields.items %} {% if group_name %} -
    {{ group_name }}
    +
    {{ group_name }}
    {% endif %} {% for field, value in fields.items %} diff --git a/netbox/utilities/templates/form_helpers/render_custom_fields.html b/netbox/utilities/templates/form_helpers/render_custom_fields.html index f3e5bffa9..6b0b2840b 100644 --- a/netbox/utilities/templates/form_helpers/render_custom_fields.html +++ b/netbox/utilities/templates/form_helpers/render_custom_fields.html @@ -1,7 +1,12 @@ {% load form_helpers %} -{% for field in form %} - {% if field.name in form.custom_fields %} - {% render_field field %} - {% endif %} +{% for group, fields in form.custom_field_groups.items %} + {% if group %} +
    +
    {{ group }}
    +
    + {% endif %} + {% for name in fields %} + {% render_field form|getfield:name %} + {% endfor %} {% endfor %} From a6be8dccf55726ddd1c9a8319f4a162c43023fa0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Jul 2022 15:45:47 -0400 Subject: [PATCH 185/245] Fixes #9847: Respect desc_units when ordering rack units --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/models/racks.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 7e8fd50e3..87cb00730 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -108,6 +108,7 @@ Custom field UI visibility has no impact on API operation. * [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects * [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647) * [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination +* [#9847](https://github.com/netbox-community/netbox/issues/9847) - Respect `desc_units` when ordering rack units ### Plugins API diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 2039def09..4dcfcde28 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -244,10 +244,9 @@ class Rack(NetBoxModel): """ Return a list of unit numbers, top to bottom. """ - max_position = self.u_height + decimal.Decimal(0.5) if self.desc_units: - drange(0.5, max_position, 0.5) - return drange(max_position, 0.5, -0.5) + return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5) + return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) From c5fb7b72f0eb2b1e3f53b3397090b0470398f725 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Jul 2022 14:36:20 -0400 Subject: [PATCH 186/245] Closes #9391: Remove 500-character limit for custom link text & URL fields --- docs/release-notes/version-3.3.md | 1 + .../0077_customlink_extend_text_and_url.py | 21 +++++++++++++++++++ netbox/extras/models/models.py | 6 ++---- 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 netbox/extras/migrations/0077_customlink_extend_text_and_url.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 87cb00730..211f264f0 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -93,6 +93,7 @@ Custom field UI visibility has no impact on API operation. * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results * [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions * [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links +* [#9391](https://github.com/netbox-community/netbox/issues/9391) - Remove 500-character limit for custom link text & URL fields * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location diff --git a/netbox/extras/migrations/0077_customlink_extend_text_and_url.py b/netbox/extras/migrations/0077_customlink_extend_text_and_url.py new file mode 100644 index 000000000..c08948aa6 --- /dev/null +++ b/netbox/extras/migrations/0077_customlink_extend_text_and_url.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0076_tag_slug_unicode'), + ] + + operations = [ + migrations.AlterField( + model_name='customlink', + name='link_text', + field=models.TextField(), + ), + migrations.AlterField( + model_name='customlink', + name='link_url', + field=models.TextField(), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e614a1258..4873a1f9e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -204,12 +204,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): enabled = models.BooleanField( default=True ) - link_text = models.CharField( - max_length=500, + link_text = models.TextField( help_text="Jinja2 template code for link text" ) - link_url = models.CharField( - max_length=500, + link_url = models.TextField( verbose_name='Link URL', help_text="Jinja2 template code for link URL" ) From 2c43c8d077c34402720b86658674c46371da321d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Jul 2022 15:03:24 -0400 Subject: [PATCH 187/245] Closes #9793: Add PoE attributes to interface templates --- docs/models/dcim/interfacetemplate.md | 2 +- docs/release-notes/version-3.3.md | 4 +++- netbox/dcim/api/serializers.py | 12 +++++++++++- netbox/dcim/filtersets.py | 6 ++++++ netbox/dcim/forms/bulk_edit.py | 16 +++++++++++++++- netbox/dcim/forms/filtersets.py | 6 ++++-- netbox/dcim/forms/models.py | 4 +++- netbox/dcim/forms/object_import.py | 14 ++++++++++++-- netbox/dcim/graphql/types.py | 6 ++++++ .../migrations/0155_interface_poe_mode_type.py | 10 ++++++++++ netbox/dcim/models/device_component_templates.py | 16 +++++++++++++++- netbox/dcim/models/devices.py | 2 ++ netbox/dcim/tables/devicetypes.py | 2 +- netbox/dcim/tests/test_filtersets.py | 12 ++++++++++-- 14 files changed, 99 insertions(+), 13 deletions(-) diff --git a/docs/models/dcim/interfacetemplate.md b/docs/models/dcim/interfacetemplate.md index d9b30dd87..e11abcce4 100644 --- a/docs/models/dcim/interfacetemplate.md +++ b/docs/models/dcim/interfacetemplate.md @@ -1,3 +1,3 @@ ## Interface Templates -A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." +A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." Power over Ethernet (PoE) mode and type may also be assigned to interface templates. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 211f264f0..68cff0547 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -97,7 +97,7 @@ Custom field UI visibility has no impact on API operation. * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location -### Bug Fixes +### Bug Fixes (from Beta1) * [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device * [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data @@ -177,6 +177,8 @@ Custom field UI visibility has no impact on API operation. * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` * Added the optional `poe_mode` and `poe_type` fields * Added the `l2vpn_termination` read-only field +* dcim.InterfaceTemplate + * Added the optional `poe_mode` and `poe_type` fields * dcim.Location * Added required `status` field (default value: `active`) * dcim.PowerOutlet diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5f30b7385..249a3f167 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -469,12 +469,22 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): default=None ) type = ChoiceField(choices=InterfaceTypeChoices) + poe_mode = ChoiceField( + choices=InterfacePoEModeChoices, + required=False, + allow_blank=True + ) + poe_type = ChoiceField( + choices=InterfacePoETypeChoices, + required=False, + allow_blank=True + ) class Meta: model = InterfaceTemplate fields = [ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', - 'created', 'last_updated', + 'poe_mode', 'poe_type', 'created', 'last_updated', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4bdc525a5..874d08ba5 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -652,6 +652,12 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=InterfaceTypeChoices, null_value=None ) + poe_mode = django_filters.MultipleChoiceFilter( + choices=InterfacePoEModeChoices + ) + poe_type = django_filters.MultipleChoiceFilter( + choices=InterfacePoETypeChoices + ) class Meta: model = InterfaceTemplate diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 6d51302d3..8f765ae9b 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -818,8 +818,22 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): description = forms.CharField( required=False ) + poe_mode = forms.ChoiceField( + choices=add_blank_choice(InterfacePoEModeChoices), + required=False, + initial='', + widget=StaticSelect(), + label='PoE mode' + ) + poe_type = forms.ChoiceField( + choices=add_blank_choice(InterfacePoETypeChoices), + required=False, + initial='', + widget=StaticSelect(), + label='PoE type' + ) - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description', 'poe_mode', 'poe_type') class FrontPortTemplateBulkEditForm(BulkEditForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 8d2e24c9c..c5474a2b1 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1027,11 +1027,13 @@ class InterfaceFilterForm(DeviceComponentFilterForm): ) poe_mode = MultipleChoiceField( choices=InterfacePoEModeChoices, - required=False + required=False, + label='PoE mode' ) poe_type = MultipleChoiceField( choices=InterfacePoEModeChoices, - required=False + required=False, + label='PoE type' ) rf_role = MultipleChoiceField( choices=WirelessRoleChoices, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index aa573a4df..f3ab6f3a9 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1052,12 +1052,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type', ] widgets = { 'device_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(), 'type': StaticSelect(), + 'poe_mode': StaticSelect(), + 'poe_type': StaticSelect(), } diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index afbcd6543..a51f48c5b 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -1,6 +1,6 @@ from django import forms -from dcim.choices import InterfaceTypeChoices, PortTypeChoices +from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices from dcim.models import * from utilities.forms import BootstrapMixin @@ -112,11 +112,21 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( choices=InterfaceTypeChoices.CHOICES ) + poe_mode = forms.ChoiceField( + choices=InterfacePoEModeChoices, + required=False, + label='PoE mode' + ) + poe_type = forms.ChoiceField( + choices=InterfacePoETypeChoices, + required=False, + label='PoE type' + ) class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type', ] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index a43b293a4..52a98278a 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -258,6 +258,12 @@ class InterfaceTemplateType(ComponentTemplateObjectType): fields = '__all__' filterset_class = filtersets.InterfaceTemplateFilterSet + def resolve_poe_mode(self, info): + return self.poe_mode or None + + def resolve_poe_type(self, info): + return self.poe_type or None + class InventoryItemType(ComponentObjectType): diff --git a/netbox/dcim/migrations/0155_interface_poe_mode_type.py b/netbox/dcim/migrations/0155_interface_poe_mode_type.py index 0615d5d7e..13f2ddfc0 100644 --- a/netbox/dcim/migrations/0155_interface_poe_mode_type.py +++ b/netbox/dcim/migrations/0155_interface_poe_mode_type.py @@ -20,4 +20,14 @@ class Migration(migrations.Migration): name='poe_type', field=models.CharField(blank=True, max_length=50), ), + migrations.AddField( + model_name='interfacetemplate', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interfacetemplate', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 92658d310..4a66bc457 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,6 +1,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -318,6 +318,18 @@ class InterfaceTemplate(ModularComponentTemplateModel): default=False, verbose_name='Management only' ) + poe_mode = models.CharField( + max_length=50, + choices=InterfacePoEModeChoices, + blank=True, + verbose_name='PoE mode' + ) + poe_type = models.CharField( + max_length=50, + choices=InterfacePoETypeChoices, + blank=True, + verbose_name='PoE type' + ) component_model = Interface @@ -334,6 +346,8 @@ class InterfaceTemplate(ModularComponentTemplateModel): label=self.resolve_label(kwargs.get('module')), type=self.type, mgmt_only=self.mgmt_only, + poe_mode=self.poe_mode, + poe_type=self.poe_type, **kwargs ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6075cb5a0..f8a28eb58 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -229,6 +229,8 @@ class DeviceType(NetBoxModel): 'mgmt_only': c.mgmt_only, 'label': c.label, 'description': c.description, + 'poe_mode': c.poe_mode, + 'poe_type': c.poe_type, } for c in self.interfacetemplates.all() ] diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 2da9daee7..3ed4d8c08 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -172,7 +172,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = InterfaceTemplate - fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions') + fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions') empty_text = "None" diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 21daa32c1..fbc0addb8 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1089,8 +1089,8 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceType.objects.bulk_create(device_types) InterfaceTemplate.objects.bulk_create(( - InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True), - InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False), + InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True, poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF), + InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False, poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT), InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False), )) @@ -1113,6 +1113,14 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_poe_mode(self): + params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_poe_type(self): + params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPortTemplate.objects.all() From 04fb0bd51c5a3fd0d13fddea155de311b0f4edd9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Jul 2022 15:41:10 -0400 Subject: [PATCH 188/245] Closes #9858: ChangeLoggedModelFilterSet cleanup --- netbox/netbox/filtersets.py | 23 +++++------------------ netbox/utilities/testing/filtersets.py | 4 ++-- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index f509afa5b..3a0434592 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -197,24 +197,11 @@ class BaseFilterSet(django_filters.FilterSet): class ChangeLoggedModelFilterSet(BaseFilterSet): - created = django_filters.DateTimeFilter() - created__gte = django_filters.DateTimeFilter( - field_name='created', - lookup_expr='gte' - ) - created__lte = django_filters.DateTimeFilter( - field_name='created', - lookup_expr='lte' - ) - last_updated = django_filters.DateTimeFilter() - last_updated__gte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='gte' - ) - last_updated__lte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='lte' - ) + """ + Base FilterSet for ChangeLoggedModel classes. + """ + created = filters.MultiValueDateTimeFilter() + last_updated = filters.MultiValueDateTimeFilter() class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 9c90f5530..00f3d9745 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -25,11 +25,11 @@ class ChangeLoggedFilterSetTests(BaseFilterSetTests): def test_created(self): pk_list = self.queryset.values_list('pk', flat=True)[:2] self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'created': '2021-01-01T00:00:00'} + params = {'created': ['2021-01-01T00:00:00']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_last_updated(self): pk_list = self.queryset.values_list('pk', flat=True)[:2] self.queryset.filter(pk__in=pk_list).update(last_updated=datetime(2021, 1, 2, 0, 0, 0, tzinfo=timezone.utc)) - params = {'last_updated': '2021-01-02T00:00:00'} + params = {'last_updated': ['2021-01-02T00:00:00']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From c582d7459f2bd5db72ba407b360bf381694b1767 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Jul 2022 10:06:26 -0400 Subject: [PATCH 189/245] Rearrange introductory content --- docs/index.md | 81 ++++++++++++++++++-------------------------- docs/introduction.md | 79 ++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 +- 3 files changed, 113 insertions(+), 49 deletions(-) create mode 100644 docs/introduction.md diff --git a/docs/index.md b/docs/index.md index 81c899387..a6bbcecff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,63 +1,48 @@ ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} -# What is NetBox? +# The Premiere Network Source of Truth -NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management: +NetBox is the leading solution for modeling modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. -* **IP address management (IPAM)** - IP networks and addresses, VRFs, and VLANs -* **Equipment racks** - Organized by group and site -* **Devices** - Types of devices and where they are installed -* **Connections** - Network, console, and power connections among devices -* **Virtualization** - Virtual machines and clusters -* **Data circuits** - Long-haul communications circuits and providers +## Built for Networks -## What NetBox Is Not +Unlike general-purpose CMDBs, NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of network engineers and operators. These cover all facets of network design, from IP address managements to cabling to overlays and more: -While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide: +* Hierarchical regions, site groups, sites, and locations +* Racks, devices, and device components +* Cables and wireless connections +* Power distribution +* Data circuits and providers +* Virtual machines and clusters +* IP prefixes, ranges, and addresses +* VRFs and route targets +* FHRP groups (VRRP, HSRP, etc.) +* AS numbers +* VLANs and scoped VLAN groups +* L2VPN overlays +* Organizational tenants and contacts -* Network monitoring -* DNS server -* RADIUS server -* Configuration management -* Facilities management +## Customizable & Extensible -That said, NetBox _can_ be used to great effect in populating external tools with the data they need to perform these functions. +In addition to its expansive and robust data model, NetBox offers myriad mechanisms through it can be customized and extended. -## Design Philosophy +* Custom fields +* Custom model validation +* Export templates +* Webhooks +* Plugins +* REST & GraphQL APIs -NetBox was designed with the following tenets foremost in mind. +## Always Open -### Replicate the Real World - -Careful consideration has been given to the data model to ensure that it can accurately reflect a real-world network. For instance, IP addresses are assigned not to devices, but to specific interfaces attached to a device, and an interface may have multiple IP addresses assigned to it. - -### Serve as a "Source of Truth" - -NetBox intends to represent the _desired_ state of a network versus its _operational_ state. As such, automated import of live network state is strongly discouraged. All data created in NetBox should first be vetted by a human to ensure its integrity. NetBox can then be used to populate monitoring and provisioning systems with a high degree of confidence. - -### Keep it Simple - -When given a choice between a relatively simple [80% solution](https://en.wikipedia.org/wiki/Pareto_principle) and a much more complex complete solution, the former will typically be favored. This ensures a lean codebase with a low learning curve. - -## Application Stack - -NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server. - -| Function | Component | -|--------------------|-------------------| -| HTTP service | nginx or Apache | -| WSGI service | gunicorn or uWSGI | -| Application | Django/Python | -| Database | PostgreSQL 10+ | -| Task queuing | Redis/django-rq | -| Live device access | NAPALM (optional) | - -## Supported Python Versions - -NetBox supports Python 3.8, 3.9, and 3.10 environments. +Because NetBox is an open source application licensed under [Apache 2](https://www.apache.org/licenses/LICENSE-2.0.html), its entire code base is completely accessible to the end user, and there's never a risk of vendor lock-out. Additionally, NetBox development is an entirely public, community-driven process to which everyone can provide input. ## Getting Started -Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible. +* Public Demo +* Installation Guide +* Docker install +* NetBox Cloud -Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox. +!!! tip "NetBox Development" + Interested in contributing to NetBox? Check out our [GitHub repository](https://github.com/netbox-community/netbox) to get started! diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 000000000..cffcb37dd --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,79 @@ +# Introduction to NetBox + +## Origin Story + +NetBox was originally developed by its lead maintainer, [Jeremy Stretch](https://github.com/jeremystretch), while he was working as a network engineer at [DigitalOcean](https://www.digitalocean.com/) in 2015 as part of an effort to automate their network provisioning. Recognizing the new tool's potential, DigitalOcean agreed to release it as an open source project in June 2016. + +Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation. + +## Key Features + +NetBox was built specifically to serve the needs of network engineers and operators. Below is a very brief overview of the core features it provides. + +* IP address management (IPAM) with full IPv4/IPv6 parity +* Automatic provisioning of next available prefix/IP +* VRFs with import & export route targets +* VLANs with variably-scoped groups +* AS number (ASN) management +* Rack elevations with SVG rendering +* Device modeling using pre-defined types +* Network, power, and console cabling with SVG traces +* Power distribution modeling +* Data circuit and provider tracking +* Wireless LAN and point-to-point links +* L2 VPN overlays +* FHRP groups (VRRP, HSRP, etc.) +* Application service bindings +* Virtual machines & clusters +* Flexible hierarchy for sites and locations +* Tenant ownership assignment +* Device & VM configuration contexts for advanced configuration rendering +* Custom fields for data model extension +* Support for custom validation rules +* Custom reports & scripts executable directly within the UI +* Extensive plugin framework for adding custom functionality +* Single sign-on (SSO) authentication +* Robust object-based permissions +* Detailed, automatic change logging +* NAPALM integration + +## What NetBox Is Not + +While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide: + +* Network monitoring +* DNS server +* RADIUS server +* Configuration management +* Facilities management + +That said, NetBox _can_ be used to great effect in populating external tools with the data they need to perform these functions. + +## Design Philosophy + +NetBox was designed with the following tenets foremost in mind. + +### Replicate the Real World + +Careful consideration has been given to the data model to ensure that it can accurately reflect a real-world network. For instance, IP addresses are assigned not to devices, but to specific interfaces attached to a device, and an interface may have multiple IP addresses assigned to it. + +### Serve as a "Source of Truth" + +NetBox intends to represent the _desired_ state of a network versus its _operational_ state. As such, automated import of live network state is strongly discouraged. All data created in NetBox should first be vetted by a human to ensure its integrity. NetBox can then be used to populate monitoring and provisioning systems with a high degree of confidence. + +### Keep it Simple + +When given a choice between a relatively simple [80% solution](https://en.wikipedia.org/wiki/Pareto_principle) and a much more complex complete solution, the former will typically be favored. This ensures a lean codebase with a low learning curve. + +## Application Stack + +NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server. + +| Function | Component | +|--------------------|-------------------| +| HTTP service | nginx or Apache | +| WSGI service | gunicorn or uWSGI | +| Application | Django/Python | +| Database | PostgreSQL 10+ | +| Task queuing | Redis/django-rq | +| Live device access | NAPALM (optional) | diff --git a/mkdocs.yml b/mkdocs.yml index 34c65ed01..992859d66 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,7 +57,7 @@ markdown_extensions: - pymdownx.tabbed: alternate_style: true nav: - - Introduction: 'index.md' + - Introduction: 'introduction.md' - Installation: - Installing NetBox: 'installation/index.md' - 1. PostgreSQL: 'installation/1-postgresql.md' From 18acac18e0301ad0e3355f9742177062aad403c3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Jul 2022 10:30:47 -0400 Subject: [PATCH 190/245] Move data model definitions to separate hierarchy --- docs/core-functionality/circuits.md | 10 -- docs/core-functionality/contacts.md | 5 - docs/core-functionality/device-types.md | 41 --------- docs/core-functionality/devices.md | 40 -------- docs/core-functionality/ipam.md | 33 ------- docs/core-functionality/modules.md | 4 - docs/core-functionality/power.md | 8 -- docs/core-functionality/services.md | 4 - docs/core-functionality/sites-and-racks.md | 12 --- docs/core-functionality/tenancy.md | 4 - docs/core-functionality/virtualization.md | 10 -- docs/core-functionality/vlans.md | 4 - docs/core-functionality/wireless.md | 8 -- docs/installation/migrating-to-systemd.md | 55 ----------- mkdocs.yml | 101 +++++++++++++++++---- 15 files changed, 85 insertions(+), 254 deletions(-) delete mode 100644 docs/core-functionality/circuits.md delete mode 100644 docs/core-functionality/contacts.md delete mode 100644 docs/core-functionality/device-types.md delete mode 100644 docs/core-functionality/devices.md delete mode 100644 docs/core-functionality/ipam.md delete mode 100644 docs/core-functionality/modules.md delete mode 100644 docs/core-functionality/power.md delete mode 100644 docs/core-functionality/services.md delete mode 100644 docs/core-functionality/sites-and-racks.md delete mode 100644 docs/core-functionality/tenancy.md delete mode 100644 docs/core-functionality/virtualization.md delete mode 100644 docs/core-functionality/vlans.md delete mode 100644 docs/core-functionality/wireless.md delete mode 100644 docs/installation/migrating-to-systemd.md diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md deleted file mode 100644 index b1b02e300..000000000 --- a/docs/core-functionality/circuits.md +++ /dev/null @@ -1,10 +0,0 @@ -# Circuits - -{!models/circuits/provider.md!} -{!models/circuits/providernetwork.md!} - ---- - -{!models/circuits/circuit.md!} -{!models/circuits/circuittype.md!} -{!models/circuits/circuittermination.md!} diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md deleted file mode 100644 index 76a005fc0..000000000 --- a/docs/core-functionality/contacts.md +++ /dev/null @@ -1,5 +0,0 @@ -# Contacts - -{!models/tenancy/contact.md!} -{!models/tenancy/contactgroup.md!} -{!models/tenancy/contactrole.md!} diff --git a/docs/core-functionality/device-types.md b/docs/core-functionality/device-types.md deleted file mode 100644 index ec5cbacdb..000000000 --- a/docs/core-functionality/device-types.md +++ /dev/null @@ -1,41 +0,0 @@ -# Device Types - -{!models/dcim/devicetype.md!} -{!models/dcim/manufacturer.md!} - ---- - -## Device Component Templates - -Each device type is assigned a number of component templates which define the physical components within a device. These are: - -* Console ports -* Console server ports -* Power ports -* Power outlets -* Network interfaces -* Front ports -* Rear ports -* Device bays (which house child devices) - -Whenever a new device is created, its components are automatically created per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates defined: - -* One template for a console port ("Console") -* Two templates for power ports ("PSU0" and "PSU1") -* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") -* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") - -Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. - -!!! note - Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices. - -{!models/dcim/consoleporttemplate.md!} -{!models/dcim/consoleserverporttemplate.md!} -{!models/dcim/powerporttemplate.md!} -{!models/dcim/poweroutlettemplate.md!} -{!models/dcim/interfacetemplate.md!} -{!models/dcim/frontporttemplate.md!} -{!models/dcim/rearporttemplate.md!} -{!models/dcim/modulebaytemplate.md!} -{!models/dcim/devicebaytemplate.md!} diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md deleted file mode 100644 index 35c978210..000000000 --- a/docs/core-functionality/devices.md +++ /dev/null @@ -1,40 +0,0 @@ -# Devices and Cabling - -{!models/dcim/device.md!} -{!models/dcim/devicerole.md!} -{!models/dcim/platform.md!} - ---- - -## Device Components - -Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. - -{!models/dcim/consoleport.md!} -{!models/dcim/consoleserverport.md!} -{!models/dcim/powerport.md!} -{!models/dcim/poweroutlet.md!} -{!models/dcim/interface.md!} -{!models/dcim/frontport.md!} -{!models/dcim/rearport.md!} -{!models/dcim/modulebay.md!} -{!models/dcim/devicebay.md!} -{!models/dcim/inventoryitem.md!} - ---- - -{!models/dcim/virtualchassis.md!} - ---- - -{!models/dcim/cable.md!} - -In the example below, three individual cables comprise a path between devices A and D: - -![Cable path](../media/models/dcim_cable_trace.png) - -Traced from Interface 1 on Device A, NetBox will show the following path: - -* Cable 1: Interface 1 to Front Port 1 -* Cable 2: Rear Port 1 to Rear Port 2 -* Cable 3: Front Port 2 to Interface 2 diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md deleted file mode 100644 index c86819380..000000000 --- a/docs/core-functionality/ipam.md +++ /dev/null @@ -1,33 +0,0 @@ -# IP Address Management - -{!models/ipam/aggregate.md!} -{!models/ipam/rir.md!} - ---- - -{!models/ipam/prefix.md!} -{!models/ipam/role.md!} - ---- - -{!models/ipam/iprange.md!} -{!models/ipam/ipaddress.md!} - ---- - -{!models/ipam/vrf.md!} -{!models/ipam/routetarget.md!} - ---- - -{!models/ipam/fhrpgroup.md!} -{!models/ipam/fhrpgroupassignment.md!} - ---- - -{!models/ipam/asn.md!} - ---- - -{!models/ipam/l2vpn.md!} -{!models/ipam/l2vpntermination.md!} diff --git a/docs/core-functionality/modules.md b/docs/core-functionality/modules.md deleted file mode 100644 index 4d32fe18c..000000000 --- a/docs/core-functionality/modules.md +++ /dev/null @@ -1,4 +0,0 @@ -# Modules - -{!models/dcim/moduletype.md!} -{!models/dcim/module.md!} diff --git a/docs/core-functionality/power.md b/docs/core-functionality/power.md deleted file mode 100644 index 4d7d5f0ab..000000000 --- a/docs/core-functionality/power.md +++ /dev/null @@ -1,8 +0,0 @@ -# Power Tracking - -{!models/dcim/powerpanel.md!} -{!models/dcim/powerfeed.md!} - -# Example Power Topology - -![Power distribution model](../media/power_distribution.png) diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md deleted file mode 100644 index 316c7fe00..000000000 --- a/docs/core-functionality/services.md +++ /dev/null @@ -1,4 +0,0 @@ -# Service Mapping - -{!models/ipam/servicetemplate.md!} -{!models/ipam/service.md!} diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md deleted file mode 100644 index c78f2120a..000000000 --- a/docs/core-functionality/sites-and-racks.md +++ /dev/null @@ -1,12 +0,0 @@ -# Sites and Racks - -{!models/dcim/region.md!} -{!models/dcim/sitegroup.md!} -{!models/dcim/site.md!} -{!models/dcim/location.md!} - ---- - -{!models/dcim/rack.md!} -{!models/dcim/rackrole.md!} -{!models/dcim/rackreservation.md!} diff --git a/docs/core-functionality/tenancy.md b/docs/core-functionality/tenancy.md deleted file mode 100644 index fbe1ea8b9..000000000 --- a/docs/core-functionality/tenancy.md +++ /dev/null @@ -1,4 +0,0 @@ -# Tenancy Assignment - -{!models/tenancy/tenant.md!} -{!models/tenancy/tenantgroup.md!} diff --git a/docs/core-functionality/virtualization.md b/docs/core-functionality/virtualization.md deleted file mode 100644 index 220030ab2..000000000 --- a/docs/core-functionality/virtualization.md +++ /dev/null @@ -1,10 +0,0 @@ -# Virtualization - -{!models/virtualization/cluster.md!} -{!models/virtualization/clustertype.md!} -{!models/virtualization/clustergroup.md!} - ---- - -{!models/virtualization/virtualmachine.md!} -{!models/virtualization/vminterface.md!} diff --git a/docs/core-functionality/vlans.md b/docs/core-functionality/vlans.md deleted file mode 100644 index d69128765..000000000 --- a/docs/core-functionality/vlans.md +++ /dev/null @@ -1,4 +0,0 @@ -# VLAN Management - -{!models/ipam/vlan.md!} -{!models/ipam/vlangroup.md!} diff --git a/docs/core-functionality/wireless.md b/docs/core-functionality/wireless.md deleted file mode 100644 index 57133f756..000000000 --- a/docs/core-functionality/wireless.md +++ /dev/null @@ -1,8 +0,0 @@ -# Wireless Networks - -{!models/wireless/wirelesslan.md!} -{!models/wireless/wirelesslangroup.md!} - ---- - -{!models/wireless/wirelesslink.md!} diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md deleted file mode 100644 index a71b748fd..000000000 --- a/docs/installation/migrating-to-systemd.md +++ /dev/null @@ -1,55 +0,0 @@ -# Migrating to systemd - -This document contains instructions for migrating from a legacy NetBox deployment using [supervisor](http://supervisord.org/) to a systemd-based approach. - -## Ubuntu - -### Uninstall supervisord - -```no-highlight -# apt-get remove -y supervisor -``` - -### Configure systemd - -!!! note - These instructions assume the presence of a Python virtual environment at `/opt/netbox/venv`. If you have not created this environment, please refer to the [installation instructions](3-netbox.md#set-up-python-environment) for direction. - -We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory: - -```no-highlight -# cp contrib/*.service /etc/systemd/system/ -``` - -!!! note - You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data", or something else. - -Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: - -```no-highlight -# systemctl daemon-reload -# systemctl start netbox netbox-rq -# systemctl enable netbox netbox-rq -``` - -You can use the command `systemctl status netbox` to verify that the WSGI service is running: - -``` -# systemctl status netbox.service -● netbox.service - NetBox WSGI Service - Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) - Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago - Docs: https://docs.netbox.dev/ - Main PID: 11993 (gunicorn) - Tasks: 6 (limit: 2362) - CGroup: /system.slice/netbox.service - ├─11993 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... - ├─12015 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... - ├─12016 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... -... -``` - -At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running. Issue the command `journalctl -xe` to see why the services were unable to start. - -!!! info - Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment. diff --git a/mkdocs.yml b/mkdocs.yml index 992859d66..6471783e0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,7 +58,7 @@ markdown_extensions: alternate_style: true nav: - Introduction: 'introduction.md' - - Installation: + - Installation & Upgrade: - Installing NetBox: 'installation/index.md' - 1. PostgreSQL: 'installation/1-postgresql.md' - 2. Redis: 'installation/2-redis.md' @@ -67,7 +67,6 @@ nav: - 5. HTTP Server: 'installation/5-http-server.md' - 6. LDAP (Optional): 'installation/6-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' - - Migrating to systemd: 'installation/migrating-to-systemd.md' - Configuration: - Configuring NetBox: 'configuration/index.md' - Required Settings: 'configuration/required-settings.md' @@ -75,20 +74,6 @@ nav: - Dynamic Settings: 'configuration/dynamic-settings.md' - Error Reporting: 'configuration/error-reporting.md' - Remote Authentication: 'configuration/remote-authentication.md' - - Core Functionality: - - IP Address Management: 'core-functionality/ipam.md' - - VLAN Management: 'core-functionality/vlans.md' - - Sites and Racks: 'core-functionality/sites-and-racks.md' - - Devices and Cabling: 'core-functionality/devices.md' - - Device Types: 'core-functionality/device-types.md' - - Modules: 'core-functionality/modules.md' - - Virtualization: 'core-functionality/virtualization.md' - - Service Mapping: 'core-functionality/services.md' - - Circuits: 'core-functionality/circuits.md' - - Wireless: 'core-functionality/wireless.md' - - Power Tracking: 'core-functionality/power.md' - - Tenancy: 'core-functionality/tenancy.md' - - Contacts: 'core-functionality/contacts.md' - Customization: - Custom Fields: 'customization/custom-fields.md' - Custom Validation: 'customization/custom-validation.md' @@ -135,6 +120,90 @@ nav: - Authentication: 'rest-api/authentication.md' - GraphQL API: - Overview: 'graphql-api/overview.md' + - Data Model: + - Circuits: + - Circuit: 'models/circuits/circuit.md' + - Circuit Termination: 'models/circuits/circuittermination.md' + - Circuit Type: 'models/circuits/circuittype.md' + - Provider: 'models/circuits/provider.md' + - Provider Network: 'models/circuits/providernetwork.md' + - DCIM: + - Cable: 'models/dcim/cable.md' + - CablePath: 'models/dcim/cablepath.md' + - CableTermination: 'models/dcim/cabletermination.md' + - ConsolePort: 'models/dcim/consoleport.md' + - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md' + - ConsoleServerPort: 'models/dcim/consoleserverport.md' + - ConsoleServerPortTemplate: 'models/dcim/consoleserverporttemplate.md' + - Device: 'models/dcim/device.md' + - DeviceBay: 'models/dcim/devicebay.md' + - DeviceBayTemplate: 'models/dcim/devicebaytemplate.md' + - DeviceRole: 'models/dcim/devicerole.md' + - DeviceType: 'models/dcim/devicetype.md' + - FrontPort: 'models/dcim/frontport.md' + - FrontPortTemplate: 'models/dcim/frontporttemplate.md' + - Interface: 'models/dcim/interface.md' + - InterfaceTemplate: 'models/dcim/interfacetemplate.md' + - InventoryItem: 'models/dcim/inventoryitem.md' + - InventoryItemRole: 'models/dcim/inventoryitemrole.md' + - InventoryItemTemplate: 'models/dcim/inventoryitemtemplate.md' + - Location: 'models/dcim/location.md' + - Manufacturer: 'models/dcim/manufacturer.md' + - Module: 'models/dcim/module.md' + - ModuleBay: 'models/dcim/modulebay.md' + - ModuleBayTemplate: 'models/dcim/modulebaytemplate.md' + - ModuleType: 'models/dcim/moduletype.md' + - Platform: 'models/dcim/platform.md' + - PowerFeed: 'models/dcim/powerfeed.md' + - PowerOutlet: 'models/dcim/poweroutlet.md' + - PowerOutletTemplate: 'models/dcim/poweroutlettemplate.md' + - PowerPanel: 'models/dcim/powerpanel.md' + - PowerPort: 'models/dcim/powerport.md' + - PowerPortTemplate: 'models/dcim/powerporttemplate.md' + - Rack: 'models/dcim/rack.md' + - RackReservation: 'models/dcim/rackreservation.md' + - RackRole: 'models/dcim/rackrole.md' + - RearPort: 'models/dcim/rearport.md' + - RearPortTemplate: 'models/dcim/rearporttemplate.md' + - Region: 'models/dcim/region.md' + - Site: 'models/dcim/site.md' + - SiteGroup: 'models/dcim/sitegroup.md' + - VirtualChassis: 'models/dcim/virtualchassis.md' + - IPAM: + - ASN: 'models/ipam/asn.md' + - Aggregate: 'models/ipam/aggregate.md' + - FHRPGroup: 'models/ipam/fhrpgroup.md' + - FHRPGroupAssignment: 'models/ipam/fhrpgroupassignment.md' + - IPAddress: 'models/ipam/ipaddress.md' + - IPRange: 'models/ipam/iprange.md' + - L2VPN: 'models/ipam/l2vpn.md' + - L2VPNTermination: 'models/ipam/l2vpntermination.md' + - Prefix: 'models/ipam/prefix.md' + - RIR: 'models/ipam/rir.md' + - Role: 'models/ipam/role.md' + - RouteTarget: 'models/ipam/routetarget.md' + - Service: 'models/ipam/service.md' + - ServiceTemplate: 'models/ipam/servicetemplate.md' + - VLAN: 'models/ipam/vlan.md' + - VLANGroup: 'models/ipam/vlangroup.md' + - VRF: 'models/ipam/vrf.md' + - Tenancy: + - Contact: 'models/tenancy/contact.md' + - ContactAssignment: 'models/tenancy/contactassignment.md' + - ContactGroup: 'models/tenancy/contactgroup.md' + - ContactRole: 'models/tenancy/contactrole.md' + - Tenant: 'models/tenancy/tenant.md' + - TenantGroup: 'models/tenancy/tenantgroup.md' + - Virtualization: + - Cluster: 'models/virtualization/cluster.md' + - ClusterGroup: 'models/virtualization/clustergroup.md' + - ClusterType: 'models/virtualization/clustertype.md' + - VMInterface: 'models/virtualization/vminterface.md' + - VirtualMachine: 'models/virtualization/virtualmachine.md' + - Wireless: + - WirelessLAN: 'models/wireless/wirelesslan.md' + - WirelessLANGroup: 'models/wireless/wirelesslangroup.md' + - WirelessLink: 'models/wireless/wirelesslink.md' - Reference: - Conditions: 'reference/conditions.md' - Markdown: 'reference/markdown.md' From b1ce8bd222a7b39bcacbe1d66ce5ab69f11dfe8e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Jul 2022 13:45:59 -0400 Subject: [PATCH 191/245] Started "getting started" guide --- docs/getting-started/planning.md | 87 +++++++++++++++++++++++++ docs/getting-started/populating-data.md | 42 ++++++++++++ mkdocs.yml | 3 + 3 files changed, 132 insertions(+) create mode 100644 docs/getting-started/planning.md create mode 100644 docs/getting-started/populating-data.md diff --git a/docs/getting-started/planning.md b/docs/getting-started/planning.md new file mode 100644 index 000000000..00640ca44 --- /dev/null +++ b/docs/getting-started/planning.md @@ -0,0 +1,87 @@ +# Planning Your Move + +This guide outlines the steps necessary for planning a successful migration to NetBox. Although it is written under the context of a completely new installation, the general approach outlined here works just as well for adding new data to existing NetBox deployments. + +## Identify Current Sources of Truth + +Before beginning to use NetBox for your own data, it's crucial to first understand where your existing sources of truth reside. A "source of truth" is really just any repository of data that is authoritative for a given domain. For example, you may have a spreadsheet which tracks all IP prefixes in use on your network. So long as everyone involved agrees that this spreadsheet is _authoritative_ for the entire network, it is your source of truth for IP prefixes. + +Anything can be a source of truth, provided it meets two conditions: + +1. It is agreed upon by all relevant parties that this source of data is correct. +2. The domain to which it applies is well-defined. + + + +Dedicate some time to take stock of your own sources of truth for your infrastructure. Upon attempting to catalog these, you're very likely to encounter some challenges, such as: + +* **Multiple conflicting sources** for a given domain. For example, there may be multiple versions of a spreadsheet circulating, each of which asserts a conflicting set of data. +* **Sources with no domain defined.** You may encounter that different teams within your organization use different tools for the same purpose, with no normal definition of when either should be used. +* **Inaccessible data formatting.** Some tools are better suited for programmatic usage than others. For example, spreadsheets are generally very easy to parse and export, however free-form notes on wiki or similar application are much more difficult to consume. +* **There is no source of truth.** Sometimes you'll find that a source of truth simply doesn't exist for a domain. For example, when assigning IP addresses, operators may be just using any (presumed) available IP from a subnet without ever recording its usage. + +See if you can identify each domain of infrastructure data for your organization, and the source of truth for each. Once you have these compiled, you'll need to determine what belongs in NetBox. + +## Determine What to Move + +The general rule when determining what data to put into NetBox is this: If there's a model for it, it belongs in NetBox. For instance, NetBox has purpose-built models for racks, devices, cables, IP prefixes, VLANs, and so on. These are very straightforward to use. However, you'll inevitably reach the limits of NetBox's data model and question what additional data might make sense to record in NetBox. For example, you might wonder whether NetBox should function as the source of truth for infrastructure DNS records or DHCP scopes. + +NetBox provides two core mechanisms for extending its data model. The first is custom fields: Most models in NetBox support the addition of custom fields to hold additional data for which a built-in field does not exist. For example, you might wish to add an "inventory ID" field to the device model. The second mechanism is plugins. Users can create their own plugins to introduce entirely new models, views, and API endpoints in NetBox. This can be incredibly powerful, as it enables rapid development and tight integration with core models. + +That said, it doesn't always make sense to migrate a domain of data to NetBox. For example, many organizations opt to use only the IPAM components or only the DCIM components of NetBox, and integrate with other sources of truth for different domains. This is an entirely valid approach (so long as everyone involved agrees which tool is authoritative for each domain). Ultimately, you'll need to weigh the value of having non-native data models in NetBox against the effort required to define and maintain those models. + +Consider also that NetBox is under constant development. Although the current release might not support a particular type of object, there may be plans to add support for it in a future release. (And if there aren't, consider submitting a feature request citing your use case.) + +## Validate Existing Data + +The last step before migrating data to NetBox is the most crucial: **validation**. The GIGO (garbage in, garbage out) principle is in full effect: Your source of truth is only as good as the data it holds. While NetBox has very powerful data validation tools (including support for custom validation rules), ultimately the onus falls to a human operator to assert what is correct and what is not. For example, NetBox can validate the connection of a cable between two interfaces, but it cannot say whether the cable _should_ be there. + +Here are some tips to help ensure you're only importing valid data into NetBox: + +* Ensure you're starting with complete, well-formatted data. JSON or CSV is highly recommended for the best portability. +* Consider defining custom validation rules in NetBox prior to import. (For example, to enforce device naming schemes.) +* Use custom scripts to automatically populate patterned data. (For example, to automatically create a set of standard VLANs for each site.) + +There are several methods available to import data into NetBox, which we'll cover in the next section. + +## Order of Operations + +When starting with a completely empty database, it might not be immediately clear where to begin. Many models in NetBox rely on the advance creation of other types. For example, you cannot create a device type until after you have created its manufacturer. + +Below is the (rough) recommended order in which NetBox objects should be created or imported. While it is not required to follow this exact order, doing so will help ensure the smoothest workflow. + + + +1. Tenant groups +2. Tenants +3. Regions and/or site groups +4. Sites +5. Locations +6. Rack roles +7. Racks +8. Platforms +9. Manufacturers +10. Device types +11. Module types +12. Device roles +13. Devices +14. Providers +15. Provider networks +16. Circuit types +17. Circuits +18. Wireless LAN groups +19. Wireless LANs & links +20. Route targets +21. VRFs +22. RIRs +23. Aggregates +24. IP/VLAN roles +25. Prefixes +26. IP ranges & addresses +27. VLAN groups +28. VLANs +29. Services +30. Clusters +31. Virtual machines +32. VM interfaces +33. L2 VPNs diff --git a/docs/getting-started/populating-data.md b/docs/getting-started/populating-data.md new file mode 100644 index 000000000..e182a9d52 --- /dev/null +++ b/docs/getting-started/populating-data.md @@ -0,0 +1,42 @@ +# Populating Data + +This section covers the mechanisms which are available to populate data in NetBox. + +## Manual Object Creation + +The simplest and most direct way of populating data in NetBox is to use the object creation forms in the user interface. + +!!! warning "Not Ideal for Large Imports" + While convenient and readily accessible to even novice users, creating objects one at a time by manually completing these forms obviously does not scale well. For large imports, you're generally best served by using one of the other methods discussed in this section. + +To create a new object in NetBox, find the object type in the navigation menu and click the green "Add" button. + +!!! info "Missing Button?" + If you don't see an "add" button for certain object types, it's likely that your account does not have sufficient permission to create these types. Ask your NetBox administrator to grant the required permissions. + + Also note that some object types, such as device components, cannot be created directly from the navigation menu. These must be created within the context of a parent object (such as a parent device). + + + +## Bulk Import (CSV/YAML) + +NetBox supports the bulk import of new objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file. + +When viewing the CSV import form for an object type, you'll notice that the headers for the required columns have been pre-populated. Each form has a table beneath it titled "CSV Field Options," which lists _all_ supported columns for your reference. (Generally, these map to the fields you see in the corresponding creation form for individual objects.) + + + +Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components. + +## Scripting + +Sometimes you'll find that data you need to populate in NetBox can be easily reduced to a pattern. For example, suppose you have one hundred branch sites and each site gets five VLANs, numbered 101 through 105. While it's certainly possible to explicitly define each of these 500 VLANs in a CSV file for import, it may be quicker to draft a simple custom script to automatically create these VLANs according to the pattern. This ensures a high degree of confidence in the validity of the data, since it's impossible for a script to "miss" a VLAN here or there. + +!!! tip "Reconstruct Existing Data with Scripts" + Sometimes, you might want to write a script to populate objects even if you have the necessary data ready for import. This is because using a script eliminates the need to manually verify existing data prior to import. + +## REST API + +You can also use the REST API to facilitate the population of data in NetBox. The REST API offers full programmatic control over the creation of objects, subject to the same validation rules enforced by the UI forms. Additionally, the REST API supports the bulk creation of multiple objects using a single request. + +For more information about this option, see the [REST API documentation](../rest-api/overview.md). diff --git a/mkdocs.yml b/mkdocs.yml index 6471783e0..2203b8934 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,9 @@ nav: - 5. HTTP Server: 'installation/5-http-server.md' - 6. LDAP (Optional): 'installation/6-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' + - Getting Started: + - Planning: 'getting-started/planning.md' + - Populating Data: 'getting-started/populating-data.md' - Configuration: - Configuring NetBox: 'configuration/index.md' - Required Settings: 'configuration/required-settings.md' From a6c431f3ba236760ab753f92b8b7aec16c07c568 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Jul 2022 15:10:50 -0400 Subject: [PATCH 192/245] Reorganize configuration docs --- docs/additional-features/napalm.md | 2 +- docs/administration/housekeeping.md | 4 +- docs/configuration/data-validation.md | 86 +++ docs/configuration/date-time.md | 20 + docs/configuration/default-values.md | 77 +++ docs/configuration/development.md | 21 + docs/configuration/dynamic-settings.md | 232 --------- docs/configuration/index.md | 44 +- docs/configuration/miscellaneous.md | 159 ++++++ docs/configuration/napalm.md | 51 ++ docs/configuration/optional-settings.md | 489 ------------------ docs/configuration/plugins.md | 35 ++ docs/configuration/remote-authentication.md | 32 +- ...red-settings.md => required-parameters.md} | 0 docs/configuration/security.md | 144 ++++++ docs/configuration/system.md | 178 +++++++ docs/customization/custom-validation.md | 2 +- docs/customization/export-templates.md | 2 +- docs/customization/reports.md | 2 +- docs/graphql-api/overview.md | 2 +- docs/installation/3-netbox.md | 6 +- docs/installation/6-ldap.md | 2 +- docs/plugins/development/models.md | 2 +- docs/release-notes/version-3.0.md | 2 +- docs/release-notes/version-3.1.md | 2 - docs/release-notes/version-3.2.md | 4 +- docs/rest-api/authentication.md | 2 +- docs/rest-api/overview.md | 4 +- mkdocs.yml | 15 +- 29 files changed, 851 insertions(+), 770 deletions(-) create mode 100644 docs/configuration/data-validation.md create mode 100644 docs/configuration/date-time.md create mode 100644 docs/configuration/default-values.md create mode 100644 docs/configuration/development.md delete mode 100644 docs/configuration/dynamic-settings.md create mode 100644 docs/configuration/miscellaneous.md create mode 100644 docs/configuration/napalm.md delete mode 100644 docs/configuration/optional-settings.md create mode 100644 docs/configuration/plugins.md rename docs/configuration/{required-settings.md => required-parameters.md} (100%) create mode 100644 docs/configuration/security.md create mode 100644 docs/configuration/system.md diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index 2387bc8b7..60d8014e2 100644 --- a/docs/additional-features/napalm.md +++ b/docs/additional-features/napalm.md @@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment ## Authentication -By default, the [`NAPALM_USERNAME`](../configuration/dynamic-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/dynamic-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. +By default, the [`NAPALM_USERNAME`](../configuration/napalm.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/napalm.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. ``` $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md index 1989e41c0..da1a5443b 100644 --- a/docs/administration/housekeeping.md +++ b/docs/administration/housekeeping.md @@ -3,8 +3,8 @@ NetBox includes a `housekeeping` management command that should be run nightly. This command handles: * Clearing expired authentication sessions from the database -* Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention) -* Deleting job result records older than the configured [retention time](../configuration/dynamic-settings.md#jobresult_retention) +* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention) +* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#jobresult_retention) This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md new file mode 100644 index 000000000..e4eb4baff --- /dev/null +++ b/docs/configuration/data-validation.md @@ -0,0 +1,86 @@ +# Data & Validation Parameters + +## CUSTOM_VALIDATORS + +!!! tip "Dynamic Configuration Parameter" + +This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: + +```python +CUSTOM_VALIDATORS = { + "dcim.site": [ + { + "name": { + "min_length": 5, + "max_length": 30 + } + }, + "my_plugin.validators.Validator1" + ], + "dim.device": [ + "my_plugin.validators.Validator1" + ] +} +``` + +--- + +## FIELD_CHOICES + +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) + +The choices provided can either replace the stock choices provided by NetBox, or append to them. To _replace_ the available choices, specify the app, model, and field name separated by dots. For example, the site model would be referenced as `dcim.Site.status`. To _extend_ the available choices, append a plus sign to the end of this string (e.g. `dcim.Site.status+`). + +For example, the following configuration would replace the default site status choices with the options Foo, Bar, and Baz: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), + ) +} +``` + +Appending a plus sign to the field identifier would instead _add_ these choices to the ones already offered: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ... + ) +} +``` + +The following model fields support configurable choices: + +* `circuits.Circuit.status` +* `dcim.Device.status` +* `dcim.Location.status` +* `dcim.PowerFeed.status` +* `dcim.Rack.status` +* `dcim.Site.status` +* `extras.JournalEntry.kind` +* `ipam.IPAddress.status` +* `ipam.IPRange.status` +* `ipam.Prefix.status` +* `ipam.VLAN.status` +* `virtualization.Cluster.status` +* `virtualization.VirtualMachine.status` + +The following colors are supported: + +* `blue` +* `indigo` +* `purple` +* `pink` +* `red` +* `orange` +* `yellow` +* `green` +* `teal` +* `cyan` +* `gray` +* `black` +* `white` diff --git a/docs/configuration/date-time.md b/docs/configuration/date-time.md new file mode 100644 index 000000000..ab8b5ad13 --- /dev/null +++ b/docs/configuration/date-time.md @@ -0,0 +1,20 @@ +# Date & Time Parameters + +## TIME_ZONE + +Default: UTC + +The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +## Date and Time Formatting + +You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below. + +```python +DATE_FORMAT = 'N j, Y' # June 26, 2016 +SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26 +TIME_FORMAT = 'g:i a' # 1:23 p.m. +SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00 +DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m. +SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-26 13:23 +``` diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md new file mode 100644 index 000000000..6d92858eb --- /dev/null +++ b/docs/configuration/default-values.md @@ -0,0 +1,77 @@ +# Default Value Parameters + +## DEFAULT_USER_PREFERENCES + +!!! tip "Dynamic Configuration Parameter" + +This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: + +```python +DEFAULT_USER_PREFERENCES = { + "pagination": { + "per_page": 100 + } +} +``` + +For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. + +--- + +## PAGINATE_COUNT + +!!! tip "Dynamic Configuration Parameter" + +Default: 50 + +The default maximum number of objects to display per page within each list of objects. + +--- + +## POWERFEED_DEFAULT_AMPERAGE + +!!! tip "Dynamic Configuration Parameter" + +Default: 15 + +The default value for the `amperage` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_MAX_UTILIZATION + +!!! tip "Dynamic Configuration Parameter" + +Default: 80 + +The default value (percentage) for the `max_utilization` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_VOLTAGE + +!!! tip "Dynamic Configuration Parameter" + +Default: 120 + +The default value for the `voltage` field when creating new power feeds. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + +!!! tip "Dynamic Configuration Parameter" + +Default: 22 + +Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_WIDTH + +!!! tip "Dynamic Configuration Parameter" + +Default: 220 + +Default width (in pixels) of a unit within a rack elevation. diff --git a/docs/configuration/development.md b/docs/configuration/development.md new file mode 100644 index 000000000..3af56b0e3 --- /dev/null +++ b/docs/configuration/development.md @@ -0,0 +1,21 @@ +# Development Parameters + +## DEBUG + +Default: False + +This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only +clients which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user +interface. + +!!! warning + Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a + substantial performance penalty. + +--- + +## DEVELOPER + +Default: False + +This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base. diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md deleted file mode 100644 index d376dc5c4..000000000 --- a/docs/configuration/dynamic-settings.md +++ /dev/null @@ -1,232 +0,0 @@ -# Dynamic Configuration Settings - -These configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These setting may also be overridden in `configuration.py`; this will prevent them from being modified via the UI. - ---- - -## ALLOWED_URL_SCHEMES - -Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` - -A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable). - ---- - -## BANNER_TOP - -## BANNER_BOTTOM - -Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set: - -```python -BANNER_TOP = 'Your banner text' -BANNER_BOTTOM = BANNER_TOP -``` - ---- - -## BANNER_LOGIN - -This defines custom content to be displayed on the login page above the login form. HTML is allowed. - ---- - -## CHANGELOG_RETENTION - -Default: 90 - -The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain -changes in the database indefinitely. - -!!! warning - If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. - ---- - -## CUSTOM_VALIDATORS - -This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: - -```python -CUSTOM_VALIDATORS = { - "dcim.site": [ - { - "name": { - "min_length": 5, - "max_length": 30 - } - }, - "my_plugin.validators.Validator1" - ], - "dim.device": [ - "my_plugin.validators.Validator1" - ] -} -``` - ---- - -## DEFAULT_USER_PREFERENCES - -This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: - -```python -DEFAULT_USER_PREFERENCES = { - "pagination": { - "per_page": 100 - } -} -``` - -For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. - ---- - -## ENFORCE_GLOBAL_UNIQUE - -Default: False - -By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True. - ---- - -## GRAPHQL_ENABLED - -Default: True - -Setting this to False will disable the GraphQL API. - ---- - -## JOBRESULT_RETENTION - -Default: 90 - -The number of days to retain job results (scripts and reports). Set this to `0` to retain -job results in the database indefinitely. - -!!! warning - If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. - ---- - -## MAINTENANCE_MODE - -Default: False - -Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled. - ---- - -## MAPS_URL - -Default: `https://maps.google.com/?q=` (Google Maps) - -This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. - ---- - -## MAX_PAGE_SIZE - -Default: 1000 - -A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`. - ---- - -## NAPALM_USERNAME - -## NAPALM_PASSWORD - -NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional. - -!!! note - If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed. - ---- - -## NAPALM_ARGS - -A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: - -```python -NAPALM_ARGS = { - 'api_key': '472071a93b60a1bd1fafb401d9f8ef41', - 'port': 2222, -} -``` - -Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument: - -```python -NAPALM_USERNAME = 'username' -NAPALM_PASSWORD = 'MySecretPassword' -NAPALM_ARGS = { - 'secret': NAPALM_PASSWORD, - # Include any additional args here -} -``` - ---- - -## NAPALM_TIMEOUT - -Default: 30 seconds - -The amount of time (in seconds) to wait for NAPALM to connect to a device. - ---- - -## PAGINATE_COUNT - -Default: 50 - -The default maximum number of objects to display per page within each list of objects. - ---- - -## POWERFEED_DEFAULT_AMPERAGE - -Default: 15 - -The default value for the `amperage` field when creating new power feeds. - ---- - -## POWERFEED_DEFAULT_MAX_UTILIZATION - -Default: 80 - -The default value (percentage) for the `max_utilization` field when creating new power feeds. - ---- - -## POWERFEED_DEFAULT_VOLTAGE - -Default: 120 - -The default value for the `voltage` field when creating new power feeds. - ---- - -## PREFER_IPV4 - -Default: False - -When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead. - ---- - -## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - -Default: 22 - -Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. - ---- - -## RACK_ELEVATION_DEFAULT_UNIT_WIDTH - -Default: 220 - -Default width (in pixels) of a unit within a rack elevation. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index a863ef3dc..42d254027 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -1,24 +1,50 @@ # NetBox Configuration -NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided as `configuration_example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below. +## Configuration File + +NetBox's configuration file contains all the important parameters which control how NetBox functions: database settings, security controls, user preferences, and so on. While the default configuration suffices out of the box for most use cases, there are a few [required parameters](./required-parameters.md) which **must** be defined during installation. + +The configuration file is loaded from `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided at `configuration_example.py`, which you may copy to use as your default config. Note that a configuration file must be defined; NetBox will not run without one. !!! info "Customizing the Configuration Module" A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`. - For the sake of brevity, the NetBox documentation refers to the configuration file simply as `configuration.py`. + To keep things simple, the NetBox documentation refers to the configuration file simply as `configuration.py`. Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI. -## Configuration Parameters +## Dynamic Configuration Parameters -* [Required settings](required-settings.md) -* [Optional settings](optional-settings.md) -* [Dynamic settings](dynamic-settings.md) -* [Remote authentication settings](remote-authentication.md) +Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below: -## Changing the Configuration +* [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes) +* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom) +* [`BANNER_LOGIN`](./miscellaneous.md#banner_login) +* [`BANNER_TOP`](./miscellaneous.md#banner_top) +* [`CHANGELOG_RETENTION`](./miscellaneous.md#changelog_retention) +* [`CUSTOM_VALIDATORS`](./data-validation.md#custom_validators) +* [`DEFAULT_USER_PREFERENCES`](./default-values.md#default_user_preferences) +* [`ENFORCE_GLOBAL_UNIQUE`](./miscellaneous.md#enforce_global_unique) +* [`GRAPHQL_ENABLED`](./miscellaneous.md#graphql_enabled) +* [`JOBRESULT_RETENTION`](./miscellaneous.md#jobresult_retention) +* [`MAINTENANCE_MODE`](./miscellaneous.md#maintenance_mode) +* [`MAPS_URL`](./miscellaneous.md#maps_url) +* [`MAX_PAGE_SIZE`](./miscellaneous.md#max_page_size) +* [`NAPALM_ARGS`](./napalm.md#napalm_args) +* [`NAPALM_PASSWORD`](./napalm.md#napalm_password) +* [`NAPALM_TIMEOUT`](./napalm.md#napalm_timeout) +* [`NAPALM_USERNAME`](./napalm.md#napalm_username) +* [`PAGINATE_COUNT`](./default-values.md#paginate_count) +* [`POWERFEED_DEFAULT_AMPERAGE`](./default-values.md#powerfeed_default_amperage) +* [`POWERFEED_DEFAULT_MAX_UTILIZATION`](./default-values.md#powerfeed_default_max_utilization) +* [`POWERFEED_DEFAULT_VOLTAGE`](./default-values.md#powerfeed_default_voltage) +* [`PREFER_IPV4`](./miscellaneous.md#prefer_ipv4) +* [`RACK_ELEVATION_DEFAULT_UNIT_HEIGHT`](./default-values.md#rack_elevation_default_unit_height) +* [`RACK_ELEVATION_DEFAULT_UNIT_WIDTH`](./default-values.md#rack_elevation_default_unit_width) -The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect: +## Modifying the Configuration + +The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before these changes will take effect: ```no-highlight $ sudo systemctl restart netbox diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md new file mode 100644 index 000000000..2aa21b7e5 --- /dev/null +++ b/docs/configuration/miscellaneous.md @@ -0,0 +1,159 @@ +# Miscellaneous Parameters + +## ADMINS + +NetBox will email details about critical errors to the administrators listed here. This should be a list of (name, email) tuples. For example: + +```python +ADMINS = [ + ['Hank Hill', 'hhill@example.com'], + ['Dale Gribble', 'dgribble@example.com'], +] +``` + +--- + +## BANNER_BOTTOM + +!!! tip "Dynamic Configuration Parameter" + +Sets content for the bottom banner in the user interface. + +--- + +## BANNER_LOGIN + +!!! tip "Dynamic Configuration Parameter" + +This defines custom content to be displayed on the login page above the login form. HTML is allowed. + +--- + +## BANNER_TOP + +!!! tip "Dynamic Configuration Parameter" + +Sets content for the top banner in the user interface. + +!!! tip + If you'd like the top and bottom banners to match, set the following: + + ```python + BANNER_TOP = 'Your banner text' + BANNER_BOTTOM = BANNER_TOP + ``` + +--- + +## CHANGELOG_RETENTION + +!!! tip "Dynamic Configuration Parameter" + +Default: 90 + +The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain +changes in the database indefinitely. + +!!! warning + If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + +## ENFORCE_GLOBAL_UNIQUE + +!!! tip "Dynamic Configuration Parameter" + +Default: False + +By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True. + +--- + +## GRAPHQL_ENABLED + +!!! tip "Dynamic Configuration Parameter" + +Default: True + +Setting this to False will disable the GraphQL API. + +--- + +## JOBRESULT_RETENTION + +!!! tip "Dynamic Configuration Parameter" + +Default: 90 + +The number of days to retain job results (scripts and reports). Set this to `0` to retain +job results in the database indefinitely. + +!!! warning + If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + +## MAINTENANCE_MODE + +!!! tip "Dynamic Configuration Parameter" + +Default: False + +Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled. + +--- + +## MAPS_URL + +!!! tip "Dynamic Configuration Parameter" + +Default: `https://maps.google.com/?q=` (Google Maps) + +This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. + +--- + +## MAX_PAGE_SIZE + +!!! tip "Dynamic Configuration Parameter" + +Default: 1000 + +A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`. + +--- + +## METRICS_ENABLED + +Default: False + +Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details. + +--- + +## PREFER_IPV4 + +!!! tip "Dynamic Configuration Parameter" + +Default: False + +When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead. + +--- + +## RELEASE_CHECK_URL + +Default: None (disabled) + +This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks. + +!!! note + The URL provided **must** be compatible with the [GitHub REST API](https://docs.github.com/en/rest). + +--- + +## RQ_DEFAULT_TIMEOUT + +Default: `300` + +The maximum execution time of a background task (such as running a custom script), in seconds. diff --git a/docs/configuration/napalm.md b/docs/configuration/napalm.md new file mode 100644 index 000000000..925ec17e6 --- /dev/null +++ b/docs/configuration/napalm.md @@ -0,0 +1,51 @@ +# NAPALM Parameters + +## NAPALM_USERNAME + +## NAPALM_PASSWORD + +!!! tip "Dynamic Configuration Parameter" + +NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional. + +!!! note + If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed. + +--- + +## NAPALM_ARGS + +!!! tip "Dynamic Configuration Parameter" + +A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: + +```python +NAPALM_ARGS = { + 'api_key': '472071a93b60a1bd1fafb401d9f8ef41', + 'port': 2222, +} +``` + +Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument: + +```python +NAPALM_USERNAME = 'username' +NAPALM_PASSWORD = 'MySecretPassword' +NAPALM_ARGS = { + 'secret': NAPALM_PASSWORD, + # Include any additional args here +} +``` + +--- + +## NAPALM_TIMEOUT + +!!! tip "Dynamic Configuration Parameter" + +Default: 30 seconds + +The amount of time (in seconds) to wait for NAPALM to connect to a device. + +--- + diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md deleted file mode 100644 index 8e5664a95..000000000 --- a/docs/configuration/optional-settings.md +++ /dev/null @@ -1,489 +0,0 @@ -# Optional Configuration Settings - -## ADMINS - -NetBox will email details about critical errors to the administrators listed here. This should be a list of (name, email) tuples. For example: - -```python -ADMINS = [ - ['Hank Hill', 'hhill@example.com'], - ['Dale Gribble', 'dgribble@example.com'], -] -``` - ---- - -## AUTH_PASSWORD_VALIDATORS - -This parameter acts as a pass-through for configuring Django's built-in password validators for local user accounts. If configured, these will be applied whenever a user's password is updated to ensure that it meets minimum criteria such as length or complexity. An example is provided below. For more detail on the available options, please see [the Django documentation](https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation). - -```python -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - 'OPTIONS': { - 'min_length': 10, - } - }, -] -``` - ---- - -## BASE_PATH - -Default: None - -The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set: - -```python -BASE_PATH = 'netbox/' -``` - ---- - -## CORS_ORIGIN_ALLOW_ALL - -Default: False - -If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below). - ---- - -## CORS_ORIGIN_WHITELIST - -## CORS_ORIGIN_REGEX_WHITELIST - -These settings specify a list of origins that are authorized to make cross-site API requests. Use -`CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular -expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example: - -```python -CORS_ORIGIN_WHITELIST = [ - 'https://example.com', -] -``` - ---- - -## CSRF_COOKIE_NAME - -Default: `csrftoken` - -The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail. - ---- - -## CSRF_TRUSTED_ORIGINS - -Default: `[]` - -Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). - -```python -CSRF_TRUSTED_ORIGINS = ( - 'http://netbox.local', - 'https://netbox.local', -) -``` - ---- - -## DEBUG - -Default: False - -This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only -clients which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user -interface. - -!!! warning - Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a - substantial performance penalty. - ---- - -## DEVELOPER - -Default: False - -This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base. - ---- - -## DOCS_ROOT - -Default: `$INSTALL_ROOT/docs/` - -The filesystem path to NetBox's documentation. This is used when presenting context-sensitive documentation in the web UI. By default, this will be the `docs/` directory within the root NetBox installation path. (Set this to `None` to disable the embedded documentation.) - ---- - -## EMAIL - -In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter: - -* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) -* `PORT` - TCP port to use for the connection (default: `25`) -* `USERNAME` - Username with which to authenticate -* `PASSSWORD` - Password with which to authenticate -* `USE_SSL` - Use SSL when connecting to the server (default: `False`) -* `USE_TLS` - Use TLS when connecting to the server (default: `False`) -* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) -* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional) -* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`) -* `FROM_EMAIL` - Sender address for emails sent by NetBox - -!!! note - The `USE_SSL` and `USE_TLS` parameters are mutually exclusive. - -Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell: - -```no-highlight -# python ./manage.py nbshell ->>> from django.core.mail import send_mail ->>> send_mail( - 'Test Email Subject', - 'Test Email Body', - 'noreply-netbox@example.com', - ['users@example.com'], - fail_silently=False -) -``` - ---- - -## EXEMPT_VIEW_PERMISSIONS - -Default: Empty list - -A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous. - -List models in the form `.`. For example: - -```python -EXEMPT_VIEW_PERMISSIONS = [ - 'dcim.site', - 'dcim.region', - 'ipam.prefix', -] -``` - -To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.) - -```python -EXEMPT_VIEW_PERMISSIONS = ['*'] -``` - -!!! note - Using a wildcard will not affect certain potentially sensitive models, such as user permissions. If there is a need to exempt these models, they must be specified individually. - ---- - -## FIELD_CHOICES - -Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) - -The choices provided can either replace the stock choices provided by NetBox, or append to them. To _replace_ the available choices, specify the app, model, and field name separated by dots. For example, the site model would be referenced as `dcim.Site.status`. To _extend_ the available choices, append a plus sign to the end of this string (e.g. `dcim.Site.status+`). - -For example, the following configuration would replace the default site status choices with the options Foo, Bar, and Baz: - -```python -FIELD_CHOICES = { - 'dcim.Site.status': ( - ('foo', 'Foo', 'red'), - ('bar', 'Bar', 'green'), - ('baz', 'Baz', 'blue'), - ) -} -``` - -Appending a plus sign to the field identifier would instead _add_ these choices to the ones already offered: - -```python -FIELD_CHOICES = { - 'dcim.Site.status+': ( - ... - ) -} -``` - -The following model fields support configurable choices: - -* `circuits.Circuit.status` -* `dcim.Device.status` -* `dcim.Location.status` -* `dcim.PowerFeed.status` -* `dcim.Rack.status` -* `dcim.Site.status` -* `extras.JournalEntry.kind` -* `ipam.IPAddress.status` -* `ipam.IPRange.status` -* `ipam.Prefix.status` -* `ipam.VLAN.status` -* `virtualization.Cluster.status` -* `virtualization.VirtualMachine.status` - -The following colors are supported: - -* `blue` -* `indigo` -* `purple` -* `pink` -* `red` -* `orange` -* `yellow` -* `green` -* `teal` -* `cyan` -* `gray` -* `black` -* `white` - ---- - -## HTTP_PROXIES - -Default: None - -A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example: - -```python -HTTP_PROXIES = { - 'http': 'http://10.10.1.10:3128', - 'https': 'http://10.10.1.10:1080', -} -``` - ---- - -## JINJA2_FILTERS - -Default: `{}` - -A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: - -```python -def uppercase(x): - return str(x).upper() - -JINJA2_FILTERS = { - 'uppercase': uppercase, -} -``` - ---- - -## INTERNAL_IPS - -Default: `('127.0.0.1', '::1')` - -A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For -example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP -addresses (and [`DEBUG`](#debug) is true). - ---- - -## LOGGING - -By default, all messages of INFO severity or higher will be logged to the console. Additionally, if [`DEBUG`](#debug) is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in [`ADMINS`](#admins). - -The Django framework on which NetBox runs allows for the customization of logging format and destination. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a local file: - -```python -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'file': { - 'level': 'INFO', - 'class': 'logging.FileHandler', - 'filename': '/var/log/netbox.log', - }, - }, - 'loggers': { - 'django': { - 'handlers': ['file'], - 'level': 'INFO', - }, - }, -} -``` - -### Available Loggers - -* `netbox..` - Generic form for model-specific log messages -* `netbox.auth.*` - Authentication events -* `netbox.api.views.*` - Views which handle business logic for the REST API -* `netbox.reports.*` - Report execution (`module.name`) -* `netbox.scripts.*` - Custom script execution (`module.name`) -* `netbox.views.*` - Views which handle business logic for the web UI - ---- - -## LOGIN_PERSISTENCE - -Default: False - -If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days. - -Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely. - ---- - -## LOGIN_REQUIRED - -Default: False - -Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes. - ---- - -## LOGIN_TIMEOUT - -Default: 1209600 seconds (14 days) - -The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login. - ---- - -## MEDIA_ROOT - -Default: $INSTALL_ROOT/netbox/media/ - -The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path. - ---- - -## METRICS_ENABLED - -Default: False - -Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details. - ---- - -## PLUGINS - -Default: Empty - -A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here. - -!!! warning - Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. - ---- - -## PLUGINS_CONFIG - -Default: Empty - -This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below: - -```python -PLUGINS_CONFIG = { - 'plugin1': { - 'foo': 123, - 'bar': True - }, - 'plugin2': { - 'foo': 456, - }, -} -``` - -Note that a plugin must be listed in `PLUGINS` for its configuration to take effect. - ---- - -## RELEASE_CHECK_URL - -Default: None (disabled) - -This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks. - -!!! note - The URL provided **must** be compatible with the [GitHub REST API](https://docs.github.com/en/rest). - ---- - -## REPORTS_ROOT - -Default: `$INSTALL_ROOT/netbox/reports/` - -The file path to the location where [custom reports](../customization/reports.md) will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. - ---- - -## RQ_DEFAULT_TIMEOUT - -Default: `300` - -The maximum execution time of a background task (such as running a custom script), in seconds. - ---- - -## SCRIPTS_ROOT - -Default: `$INSTALL_ROOT/netbox/scripts/` - -The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. - ---- - -## SESSION_COOKIE_NAME - -Default: `sessionid` - -The name used for the session cookie. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. - ---- - -## SESSION_FILE_PATH - -Default: None - -HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path. - ---- - -## STORAGE_BACKEND - -Default: None (local storage) - -The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used. - -The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. - ---- - -## STORAGE_CONFIG - -Default: Empty - -A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail. - -If `STORAGE_BACKEND` is not defined, this setting will be ignored. - ---- - -## TIME_ZONE - -Default: UTC - -The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). - ---- - -## Date and Time Formatting - -You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below. - -```python -DATE_FORMAT = 'N j, Y' # June 26, 2016 -SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26 -TIME_FORMAT = 'g:i a' # 1:23 p.m. -SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00 -DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m. -SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-26 13:23 -``` diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md new file mode 100644 index 000000000..aea60f389 --- /dev/null +++ b/docs/configuration/plugins.md @@ -0,0 +1,35 @@ +# Plugin Parameters + +## PLUGINS + +Default: Empty + +A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here. + +!!! warning + Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. + +--- + +## PLUGINS_CONFIG + +Default: Empty + +This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below: + +```python +PLUGINS_CONFIG = { + 'plugin1': { + 'foo': 123, + 'bar': True + }, + 'plugin2': { + 'foo': 456, + }, +} +``` + +Note that a plugin must be listed in `PLUGINS` for its configuration to take effect. + +--- + diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 2c3a7002f..07adf5c6a 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -47,6 +47,22 @@ NetBox can be configured to support remote user authentication by inferring user --- +## REMOTE_AUTH_GROUP_HEADER + +Default: `'HTTP_REMOTE_USER_GROUP'` + +When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + +## REMOTE_AUTH_GROUP_SEPARATOR + +Default: `|` (Pipe) + +The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + ## REMOTE_AUTH_GROUP_SYNC_ENABLED Default: `False` @@ -63,14 +79,6 @@ When remote user authentication is in use, this is the name of the HTTP header w --- -## REMOTE_AUTH_GROUP_HEADER - -Default: `'HTTP_REMOTE_USER_GROUP'` - -When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - ## REMOTE_AUTH_SUPERUSER_GROUPS Default: `[]` (Empty list) @@ -100,11 +108,3 @@ The list of groups that promote an remote User to Staff on Login. If group isn't Default: `[]` (Empty list) The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_GROUP_SEPARATOR - -Default: `|` (Pipe) - -The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-parameters.md similarity index 100% rename from docs/configuration/required-settings.md rename to docs/configuration/required-parameters.md diff --git a/docs/configuration/security.md b/docs/configuration/security.md new file mode 100644 index 000000000..6aa363b1a --- /dev/null +++ b/docs/configuration/security.md @@ -0,0 +1,144 @@ +# Security & Authentication Parameters + +## ALLOWED_URL_SCHEMES + +!!! tip "Dynamic Configuration Parameter" + +Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` + +A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable). + +--- + +## AUTH_PASSWORD_VALIDATORS + +This parameter acts as a pass-through for configuring Django's built-in password validators for local user accounts. If configured, these will be applied whenever a user's password is updated to ensure that it meets minimum criteria such as length or complexity. An example is provided below. For more detail on the available options, please see [the Django documentation](https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation). + +```python +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 10, + } + }, +] +``` + +--- + +## CORS_ORIGIN_ALLOW_ALL + +Default: False + +If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below). + +--- + +## CORS_ORIGIN_WHITELIST + +## CORS_ORIGIN_REGEX_WHITELIST + +These settings specify a list of origins that are authorized to make cross-site API requests. Use +`CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular +expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example: + +```python +CORS_ORIGIN_WHITELIST = [ + 'https://example.com', +] +``` + +--- + +## CSRF_COOKIE_NAME + +Default: `csrftoken` + +The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail. + +--- + +--- + +## CSRF_TRUSTED_ORIGINS + +Default: `[]` + +Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). + +```python +CSRF_TRUSTED_ORIGINS = ( + 'http://netbox.local', + 'https://netbox.local', +) +``` + +--- + +## EXEMPT_VIEW_PERMISSIONS + +Default: Empty list + +A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous. + +List models in the form `.`. For example: + +```python +EXEMPT_VIEW_PERMISSIONS = [ + 'dcim.site', + 'dcim.region', + 'ipam.prefix', +] +``` + +To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.) + +```python +EXEMPT_VIEW_PERMISSIONS = ['*'] +``` + +!!! note + Using a wildcard will not affect certain potentially sensitive models, such as user permissions. If there is a need to exempt these models, they must be specified individually. + +--- + +## LOGIN_PERSISTENCE + +Default: False + +If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days. + +Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely. + +--- + +## LOGIN_REQUIRED + +Default: False + +Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes. + +--- + +## LOGIN_TIMEOUT + +Default: 1209600 seconds (14 days) + +The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login. + +--- + +## SESSION_COOKIE_NAME + +Default: `sessionid` + +The name used for the session cookie. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. + +--- + +## SESSION_FILE_PATH + +Default: None + +HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path. diff --git a/docs/configuration/system.md b/docs/configuration/system.md new file mode 100644 index 000000000..21607e566 --- /dev/null +++ b/docs/configuration/system.md @@ -0,0 +1,178 @@ +# System Parameters + +## BASE_PATH + +Default: None + +The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set: + +```python +BASE_PATH = 'netbox/' +``` + +--- + +## DOCS_ROOT + +Default: `$INSTALL_ROOT/docs/` + +The filesystem path to NetBox's documentation. This is used when presenting context-sensitive documentation in the web UI. By default, this will be the `docs/` directory within the root NetBox installation path. (Set this to `None` to disable the embedded documentation.) + +--- + +## EMAIL + +In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter: + +* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) +* `PORT` - TCP port to use for the connection (default: `25`) +* `USERNAME` - Username with which to authenticate +* `PASSSWORD` - Password with which to authenticate +* `USE_SSL` - Use SSL when connecting to the server (default: `False`) +* `USE_TLS` - Use TLS when connecting to the server (default: `False`) +* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) +* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional) +* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`) +* `FROM_EMAIL` - Sender address for emails sent by NetBox + +!!! note + The `USE_SSL` and `USE_TLS` parameters are mutually exclusive. + +Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell: + +```no-highlight +# python ./manage.py nbshell +>>> from django.core.mail import send_mail +>>> send_mail( + 'Test Email Subject', + 'Test Email Body', + 'noreply-netbox@example.com', + ['users@example.com'], + fail_silently=False +) +``` + +--- + +## HTTP_PROXIES + +Default: None + +A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example: + +```python +HTTP_PROXIES = { + 'http': 'http://10.10.1.10:3128', + 'https': 'http://10.10.1.10:1080', +} +``` + +--- + +## INTERNAL_IPS + +Default: `('127.0.0.1', '::1')` + +A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For +example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP +addresses (and [`DEBUG`](#debug) is true). + +--- + +## JINJA2_FILTERS + +Default: `{}` + +A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: + +```python +def uppercase(x): + return str(x).upper() + +JINJA2_FILTERS = { + 'uppercase': uppercase, +} +``` + +--- + +## LOGGING + +By default, all messages of INFO severity or higher will be logged to the console. Additionally, if [`DEBUG`](#debug) is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in [`ADMINS`](#admins). + +The Django framework on which NetBox runs allows for the customization of logging format and destination. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a local file: + +```python +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': '/var/log/netbox.log', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['file'], + 'level': 'INFO', + }, + }, +} +``` + +### Available Loggers + +* `netbox..` - Generic form for model-specific log messages +* `netbox.auth.*` - Authentication events +* `netbox.api.views.*` - Views which handle business logic for the REST API +* `netbox.reports.*` - Report execution (`module.name`) +* `netbox.scripts.*` - Custom script execution (`module.name`) +* `netbox.views.*` - Views which handle business logic for the web UI + +--- + +## MEDIA_ROOT + +Default: $INSTALL_ROOT/netbox/media/ + +The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path. + +--- + +## REPORTS_ROOT + +Default: `$INSTALL_ROOT/netbox/reports/` + +The file path to the location where [custom reports](../customization/reports.md) will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. + +--- + +## SCRIPTS_ROOT + +Default: `$INSTALL_ROOT/netbox/scripts/` + +The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. + +--- + +## STORAGE_BACKEND + +Default: None (local storage) + +The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used. + +The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. + +--- + +## STORAGE_CONFIG + +Default: Empty + +A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail. + +If `STORAGE_BACKEND` is not defined, this setting will be ignored. + +--- diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index f88cd309b..30198117f 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -50,7 +50,7 @@ The `fail()` method may optionally specify a field with which to associate the s ## Assigning Custom Validators -Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/dynamic-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined: +Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/data-validation.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined: 1. Plain JSON mapping (no custom logic) 2. Dotted path to a custom validator class diff --git a/docs/customization/export-templates.md b/docs/customization/export-templates.md index affd39aae..3c7ff7d20 100644 --- a/docs/customization/export-templates.md +++ b/docs/customization/export-templates.md @@ -2,7 +2,7 @@ ## REST API Integration -When it is necessary to provide authentication credentials (such as when [`LOGIN_REQUIRED`](../configuration/optional-settings.md#login_required) has been enabled), it is recommended to render export templates via the REST API. This allows the client to specify an authentication token. To render an export template via the REST API, make a `GET` request to the model's list endpoint and append the `export` parameter specifying the export template name. For example: +When it is necessary to provide authentication credentials (such as when [`LOGIN_REQUIRED`](../configuration/security.md#login_required) has been enabled), it is recommended to render export templates via the REST API. This allows the client to specify an authentication token. To render an export template via the REST API, make a `GET` request to the model's list endpoint and append the `export` parameter specifying the export template name. For example: ``` GET /api/dcim/sites/?export=MyTemplateName diff --git a/docs/customization/reports.md b/docs/customization/reports.md index ae4ceb9aa..150c32f40 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -12,7 +12,7 @@ A NetBox report is a mechanism for validating the integrity of data within NetBo ## Writing Reports -Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. +Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/system.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. !!! warning The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. diff --git a/docs/graphql-api/overview.md b/docs/graphql-api/overview.md index 57dfb22bd..4fc6d2dd8 100644 --- a/docs/graphql-api/overview.md +++ b/docs/graphql-api/overview.md @@ -67,4 +67,4 @@ Authorization: Token $TOKEN ## Disabling the GraphQL API -If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/dynamic-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox. +If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/miscellaneous.md#graphql_enabled) configuration parameter to False and restarting NetBox. diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 50b350d3a..7c4a60500 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -142,7 +142,7 @@ ALLOWED_HOSTS = ['*'] ### DATABASE -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-settings.md#database) for more detail on individual parameters. +This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters. ```python DATABASE = { @@ -157,7 +157,7 @@ DATABASE = { ### REDIS -Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings.md#redis) for more detail on individual parameters. +Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters. Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID. @@ -209,7 +209,7 @@ sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt" ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`. ```no-highlight sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 281554f75..163ace70d 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -142,7 +142,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 `systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`. -For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/optional-settings.md#logging) configuration to `configuration.py`: +For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/system.md#logging) configuration to `configuration.py`: ```python LOGGING = { diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 6d7075b80..c58621b81 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -156,7 +156,7 @@ class StatusChoices(ChoiceSet): key = 'MyModel.status' ``` -To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/optional-settings.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as: +To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as: ```python FIELD_CHOICES = { diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 7d6341f44..93ff33d95 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -367,7 +367,7 @@ More information about IP ranges is available [in the documentation](../models/i #### Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963)) -This release introduces the [`CUSTOM_VALIDATORS`](../configuration/dynamic-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description: +This release introduces the [`CUSTOM_VALIDATORS`](../configuration/data-validation.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description: ```python from extras.validators import CustomValidator diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 27ba4e69e..9dce1dfd4 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -313,8 +313,6 @@ Some parameters of NetBox's configuration are now accessible via the admin UI. T Dynamic configuration parameters may also still be defined within `configuration.py`, and the settings defined here take precedence over those defined via the user interface. -For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md). - #### First Hop Redundancy Protocol (FHRP) Groups ([#6235](https://github.com/netbox-community/netbox/issues/6235)) A new FHRP group model has been introduced to aid in modeling the configurations of protocols such as HSRP, VRRP, and GLBP. Each FHRP group may be assigned one or more virtual IP addresses, as well as an authentication type and key. Member device and VM interfaces may be associated with one or more FHRP groups, with each assignment receiving a numeric priority designation. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index c36344912..10ccaeb4d 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -267,7 +267,7 @@ Custom field object assignment is fully supported in the REST API, and functions #### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) -Custom choices can be now added to most object status fields in NetBox. This is done by defining the [`FIELD_CHOICES`](../configuration/optional-settings.md#field_choices) configuration parameter to map field identifiers to an iterable of custom choices an (optionally) colors. These choices are populated automatically when NetBox initializes. For example, the following configuration will add three custom choices for the site status field, each with a designated color: +Custom choices can be now added to most object status fields in NetBox. This is done by defining the [`FIELD_CHOICES`](../configuration/data-validation.md#field_choices) configuration parameter to map field identifiers to an iterable of custom choices an (optionally) colors. These choices are populated automatically when NetBox initializes. For example, the following configuration will add three custom choices for the site status field, each with a designated color: ```python FIELD_CHOICES = { @@ -291,7 +291,7 @@ FIELD_CHOICES = { #### Improved User Preferences ([#7759](https://github.com/netbox-community/netbox/issues/7759)) -A robust new mechanism for managing user preferences is included in this release. The user preferences form has been improved for better usability, and administrators can now define default preferences for all users with the [`DEFAULT_USER_PREFERENCES`](../configuration/dynamic-settings.md##default_user_preferences) configuration parameter. For example, this can be used to define the columns which appear by default in a table: +A robust new mechanism for managing user preferences is included in this release. The user preferences form has been improved for better usability, and administrators can now define default preferences for all users with the [`DEFAULT_USER_PREFERENCES`](../configuration/default-values.md#default_user_preferences) configuration parameter. For example, this can be used to define the columns which appear by default in a table: ```python DEFAULT_USER_PREFERENCES = { diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 18b6bc4f8..411063338 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -20,7 +20,7 @@ https://netbox/api/dcim/sites/ } ``` -A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: +A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/security.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: ``` $ curl https://netbox/api/dcim/sites/ diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index 27a9b6a7e..5fc4f18bb 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -308,7 +308,7 @@ Vary: Accept } ``` -The default page is determined by the [`PAGINATE_COUNT`](../configuration/dynamic-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: +The default page is determined by the [`PAGINATE_COUNT`](../configuration/default-values.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: ``` http://netbox/api/dcim/devices/?limit=100 @@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a } ``` -The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/dynamic-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/miscellaneous.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. !!! warning Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. diff --git a/mkdocs.yml b/mkdocs.yml index 2203b8934..3acb157aa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,11 +72,18 @@ nav: - Populating Data: 'getting-started/populating-data.md' - Configuration: - Configuring NetBox: 'configuration/index.md' - - Required Settings: 'configuration/required-settings.md' - - Optional Settings: 'configuration/optional-settings.md' - - Dynamic Settings: 'configuration/dynamic-settings.md' - - Error Reporting: 'configuration/error-reporting.md' + - Required Parameters: 'configuration/required-parameters.md' + - System: 'configuration/system.md' + - Security: 'configuration/security.md' - Remote Authentication: 'configuration/remote-authentication.md' + - Data & Validation: 'configuration/data-validation.md' + - Default Values: 'configuration/default-values.md' + - Error Reporting: 'configuration/error-reporting.md' + - Plugins: 'configuration/plugins.md' + - NAPALM: 'configuration/napalm.md' + - Date & Time: 'configuration/date-time.md' + - Miscellaneous: 'configuration/miscellaneous.md' + - Development: 'configuration/development.md' - Customization: - Custom Fields: 'customization/custom-fields.md' - Custom Validation: 'customization/custom-validation.md' From 1cd407548813bc959f4959f7009b3d800fe71311 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Jul 2022 16:23:45 -0400 Subject: [PATCH 193/245] Add mermaid flowcharts showing rough data model dependencies --- docs/getting-started/planning.md | 116 ++++++++++++++++++++++--------- mkdocs.yml | 6 +- 2 files changed, 87 insertions(+), 35 deletions(-) diff --git a/docs/getting-started/planning.md b/docs/getting-started/planning.md index 00640ca44..5c431a4d2 100644 --- a/docs/getting-started/planning.md +++ b/docs/getting-started/planning.md @@ -50,38 +50,86 @@ When starting with a completely empty database, it might not be immediately clea Below is the (rough) recommended order in which NetBox objects should be created or imported. While it is not required to follow this exact order, doing so will help ensure the smoothest workflow. - +1. Tenant groups and tenants +2. Regions, site groups, sites, and locations +3. Rack roles and racks +4. Manufacturers, device types, and module types +5. Platforms and device roles +6. Devices and modules +7. Providers and provider networks +8. Circuit types and circuits +9. Wireless LAN groups and wireless LANs +10. Route targets and VRFs +11. RIRs and aggregates +12. IP/VLAN roles +13. Prefixes, IP ranges, and IP addresses +14. VLAN groups and VLANs +15. Cluster types, cluster groups, and clusters +16. Virtual machines and VM interfaces -1. Tenant groups -2. Tenants -3. Regions and/or site groups -4. Sites -5. Locations -6. Rack roles -7. Racks -8. Platforms -9. Manufacturers -10. Device types -11. Module types -12. Device roles -13. Devices -14. Providers -15. Provider networks -16. Circuit types -17. Circuits -18. Wireless LAN groups -19. Wireless LANs & links -20. Route targets -21. VRFs -22. RIRs -23. Aggregates -24. IP/VLAN roles -25. Prefixes -26. IP ranges & addresses -27. VLAN groups -28. VLANs -29. Services -30. Clusters -31. Virtual machines -32. VM interfaces -33. L2 VPNs +This is not a comprehensive list, but should suffice for the initial data imports. Beyond these, it the order in which objects are added doesn't have much if any impact. + +The graphs below illustrate some of the core dependencies among different models in NetBox for your reference. + +!!! note "Self-Nesting Models" + Each model in the graphs below which show a looping arrow pointing back to itself can be nested in a recursive hierarchy. For example, you can have regions representing both countries and cities, with the latter nested underneath the former. + +### Tenancy + +```mermaid +flowchart TD + TenantGroup --> TenantGroup + TenantGroup --> Tenant + Tenant --> Site & Device & Prefix & VLAN & ... +``` + +### Sites, Racks, and Devices + +```mermaid +flowchart TD + Region --> Region + SiteGroup --> SiteGroup + DeviceRole & Platform --> Device + Region & SiteGroup --> Site + Site --> Location & Device + Location --> Location + Location --> Rack & Device + Rack --> Device + Manufacturer --> DeviceType & ModuleType + DeviceType --> Device + Device & ModuleType ---> Module + Device & Module --> Interface +``` + +### VRFs, Prefixes, IP Addresses, and VLANs + +```mermaid +flowchart TD + VLANGroup --> VLAN + Role --> VLAN & IPRange & Prefix + RIR --> Aggregate + RouteTarget --> VRF + Aggregate & VRF --> Prefix + VRF --> IPRange & IPAddress + Prefix --> VLAN & IPRange & IPAddress +``` + +### Circuits + +```mermaid +flowchart TD + Provider & CircuitType --> Circuit + Provider --> ProviderNetwork + Circuit --> CircuitTermination +``` + +### Clusters and Virtual Machines + +```mermaid +flowchart TD + ClusterGroup & ClusterType --> Cluster + Cluster --> VirtualMachine + Site --> Cluster & VirtualMachine + Device & Platform --> VirtualMachine + VirtualMachine --> VMInterface +``` diff --git a/mkdocs.yml b/mkdocs.yml index 3acb157aa..4c5279127 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,7 +53,11 @@ markdown_extensions: - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true nav: From 6904666e2a6e9be451be3acbdd669af012aec45b Mon Sep 17 00:00:00 2001 From: Kim Johansson Date: Sat, 30 Jul 2022 01:18:30 +0200 Subject: [PATCH 194/245] Remove deprecated usage of prefetch_related Fixes #9699 --- netbox/circuits/views.py | 15 ++++-- netbox/dcim/views.py | 90 +++++++++++++++++----------------- netbox/extras/views.py | 4 +- netbox/ipam/views.py | 61 +++++++++++------------ netbox/tenancy/views.py | 10 ++-- netbox/virtualization/views.py | 15 +++--- 6 files changed, 101 insertions(+), 94 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 11f211b27..423bd67d6 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -30,7 +30,8 @@ class ProviderView(generic.ObjectView): circuits = Circuit.objects.restrict(request.user, 'view').filter( provider=instance ).prefetch_related( - 'type', 'tenant', 'tenant__group', 'terminations__site' + 'tenant__group', 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',)) circuits_table.configure(request) @@ -91,7 +92,8 @@ class ProviderNetworkView(generic.ObjectView): Q(termination_a__provider_network=instance.pk) | Q(termination_z__provider_network=instance.pk) ).prefetch_related( - 'type', 'tenant', 'tenant__group', 'terminations__site' + 'tenant__group', 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) circuits_table = tables.CircuitTable(circuits, user=request.user) circuits_table.configure(request) @@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z' + 'tenant__group', 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) filterset = filtersets.CircuitFilterSet filterset_form = forms.CircuitFilterForm @@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView): class CircuitBulkEditView(generic.BulkEditView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' + 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable @@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView): class CircuitBulkDeleteView(generic.BulkDeleteView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' + 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 12e070e70..6daecb3a6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -324,7 +324,7 @@ class SiteListView(generic.ObjectListView): class SiteView(generic.ObjectView): - queryset = Site.objects.prefetch_related('region', 'tenant__group') + queryset = Site.objects.prefetch_related('tenant__group') def get_extra_context(self, request, instance): stats = { @@ -359,7 +359,7 @@ class SiteView(generic.ObjectView): site=instance, position__isnull=True, parent_bay__isnull=True - ).prefetch_related('device_type__manufacturer') + ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance) asn_count = asns.count() @@ -391,14 +391,14 @@ class SiteBulkImportView(generic.BulkImportView): class SiteBulkEditView(generic.BulkEditView): - queryset = Site.objects.prefetch_related('region', 'tenant') + queryset = Site.objects.all() filterset = filtersets.SiteFilterSet table = tables.SiteTable form = forms.SiteBulkEditForm class SiteBulkDeleteView(generic.BulkDeleteView): - queryset = Site.objects.prefetch_related('region', 'tenant') + queryset = Site.objects.all() filterset = filtersets.SiteFilterSet table = tables.SiteTable @@ -454,7 +454,7 @@ class LocationView(generic.ObjectView): location=instance, position__isnull=True, parent_bay__isnull=True - ).prefetch_related('device_type__manufacturer') + ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') return { 'rack_count': rack_count, @@ -572,7 +572,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): # class RackListView(generic.ObjectListView): - queryset = Rack.objects.prefetch_related('devices__device_type').annotate( + queryset = Rack.objects.annotate( device_count=count_related(Device, 'rack') ) filterset = filtersets.RackFilterSet @@ -631,7 +631,7 @@ class RackView(generic.ObjectView): rack=instance, position__isnull=True, parent_bay__isnull=True - ).prefetch_related('device_type__manufacturer') + ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) @@ -682,14 +682,14 @@ class RackBulkImportView(generic.BulkImportView): class RackBulkEditView(generic.BulkEditView): - queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') + queryset = Rack.objects.all() filterset = filtersets.RackFilterSet table = tables.RackTable form = forms.RackBulkEditForm class RackBulkDeleteView(generic.BulkDeleteView): - queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') + queryset = Rack.objects.all() filterset = filtersets.RackFilterSet table = tables.RackTable @@ -706,7 +706,7 @@ class RackReservationListView(generic.ObjectListView): class RackReservationView(generic.ObjectView): - queryset = RackReservation.objects.prefetch_related('rack') + queryset = RackReservation.objects.all() class RackReservationEditView(generic.ObjectEditView): @@ -742,14 +742,14 @@ class RackReservationImportView(generic.BulkImportView): class RackReservationBulkEditView(generic.BulkEditView): - queryset = RackReservation.objects.prefetch_related('rack', 'user') + queryset = RackReservation.objects.all() filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable form = forms.RackReservationBulkEditForm class RackReservationBulkDeleteView(generic.BulkDeleteView): - queryset = RackReservation.objects.prefetch_related('rack', 'user') + queryset = RackReservation.objects.all() filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable @@ -831,7 +831,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): # class DeviceTypeListView(generic.ObjectListView): - queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( + queryset = DeviceType.objects.annotate( instance_count=count_related(Device, 'device_type') ) filterset = filtersets.DeviceTypeFilterSet @@ -840,7 +840,7 @@ class DeviceTypeListView(generic.ObjectListView): class DeviceTypeView(generic.ObjectView): - queryset = DeviceType.objects.prefetch_related('manufacturer') + queryset = DeviceType.objects.all() def get_extra_context(self, request, instance): instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count() @@ -964,7 +964,7 @@ class DeviceTypeImportView(generic.ObjectImportView): class DeviceTypeBulkEditView(generic.BulkEditView): - queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( + queryset = DeviceType.objects.annotate( instance_count=count_related(Device, 'device_type') ) filterset = filtersets.DeviceTypeFilterSet @@ -973,7 +973,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView): - queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( + queryset = DeviceType.objects.annotate( instance_count=count_related(Device, 'device_type') ) filterset = filtersets.DeviceTypeFilterSet @@ -985,7 +985,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): # class ModuleTypeListView(generic.ObjectListView): - queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + queryset = ModuleType.objects.annotate( instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet @@ -994,7 +994,7 @@ class ModuleTypeListView(generic.ObjectListView): class ModuleTypeView(generic.ObjectView): - queryset = ModuleType.objects.prefetch_related('manufacturer') + queryset = ModuleType.objects.all() def get_extra_context(self, request, instance): instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count() @@ -1091,7 +1091,7 @@ class ModuleTypeImportView(generic.ObjectImportView): class ModuleTypeBulkEditView(generic.BulkEditView): - queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + queryset = ModuleType.objects.annotate( instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet @@ -1100,7 +1100,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView): class ModuleTypeBulkDeleteView(generic.BulkDeleteView): - queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + queryset = ModuleType.objects.annotate( instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet @@ -1611,9 +1611,7 @@ class DeviceListView(generic.ObjectListView): class DeviceView(generic.ObjectView): - queryset = Device.objects.prefetch_related( - 'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' - ) + queryset = Device.objects.all() def get_extra_context(self, request, instance): # VirtualChassis members @@ -1790,14 +1788,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView): class DeviceBulkEditView(generic.BulkEditView): - queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') + queryset = Device.objects.prefetch_related('device_type__manufacturer') filterset = filtersets.DeviceFilterSet table = tables.DeviceTable form = forms.DeviceBulkEditForm class DeviceBulkDeleteView(generic.BulkDeleteView): - queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') + queryset = Device.objects.prefetch_related('device_type__manufacturer') filterset = filtersets.DeviceFilterSet table = tables.DeviceTable @@ -1807,7 +1805,7 @@ class DeviceBulkDeleteView(generic.BulkDeleteView): # class ModuleListView(generic.ObjectListView): - queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + queryset = Module.objects.prefetch_related('module_type__manufacturer') filterset = filtersets.ModuleFilterSet filterset_form = forms.ModuleFilterForm table = tables.ModuleTable @@ -1833,14 +1831,14 @@ class ModuleBulkImportView(generic.BulkImportView): class ModuleBulkEditView(generic.BulkEditView): - queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + queryset = Module.objects.prefetch_related('module_type__manufacturer') filterset = filtersets.ModuleFilterSet table = tables.ModuleTable form = forms.ModuleBulkEditForm class ModuleBulkDeleteView(generic.BulkDeleteView): - queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + queryset = Module.objects.prefetch_related('module_type__manufacturer') filterset = filtersets.ModuleFilterSet table = tables.ModuleTable @@ -2566,7 +2564,7 @@ class InventoryItemBulkImportView(generic.BulkImportView): class InventoryItemBulkEditView(generic.BulkEditView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') + queryset = InventoryItem.objects.all() filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable form = forms.InventoryItemBulkEditForm @@ -2577,7 +2575,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView): class InventoryItemBulkDeleteView(generic.BulkDeleteView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') + queryset = InventoryItem.objects.all() table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' @@ -2867,14 +2865,20 @@ class CableBulkImportView(generic.BulkImportView): class CableBulkEditView(generic.BulkEditView): - queryset = Cable.objects.prefetch_related('terminations') + queryset = Cable.objects.prefetch_related( + 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location', + 'terminations___site', + ) filterset = filtersets.CableFilterSet table = tables.CableTable form = forms.CableBulkEditForm class CableBulkDeleteView(generic.BulkDeleteView): - queryset = Cable.objects.prefetch_related('terminations') + queryset = Cable.objects.prefetch_related( + 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location', + 'terminations___site', + ) filterset = filtersets.CableFilterSet table = tables.CableTable @@ -2930,7 +2934,7 @@ class InterfaceConnectionsListView(generic.ObjectListView): # class VirtualChassisListView(generic.ObjectListView): - queryset = VirtualChassis.objects.prefetch_related('master').annotate( + queryset = VirtualChassis.objects.annotate( member_count=count_related(Device, 'virtual_chassis') ) table = tables.VirtualChassisTable @@ -3158,9 +3162,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView): # class PowerPanelListView(generic.ObjectListView): - queryset = PowerPanel.objects.prefetch_related( - 'site', 'location' - ).annotate( + queryset = PowerPanel.objects.annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) filterset = filtersets.PowerPanelFilterSet @@ -3169,10 +3171,10 @@ class PowerPanelListView(generic.ObjectListView): class PowerPanelView(generic.ObjectView): - queryset = PowerPanel.objects.prefetch_related('site', 'location') + queryset = PowerPanel.objects.all() def get_extra_context(self, request, instance): - power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack') + power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance) powerfeed_table = tables.PowerFeedTable( data=power_feeds, orderable=False @@ -3202,16 +3204,14 @@ class PowerPanelBulkImportView(generic.BulkImportView): class PowerPanelBulkEditView(generic.BulkEditView): - queryset = PowerPanel.objects.prefetch_related('site', 'location') + queryset = PowerPanel.objects.all() filterset = filtersets.PowerPanelFilterSet table = tables.PowerPanelTable form = forms.PowerPanelBulkEditForm class PowerPanelBulkDeleteView(generic.BulkDeleteView): - queryset = PowerPanel.objects.prefetch_related( - 'site', 'location' - ).annotate( + queryset = PowerPanel.objects.annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) filterset = filtersets.PowerPanelFilterSet @@ -3230,7 +3230,7 @@ class PowerFeedListView(generic.ObjectListView): class PowerFeedView(generic.ObjectView): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') + queryset = PowerFeed.objects.all() class PowerFeedEditView(generic.ObjectEditView): @@ -3249,7 +3249,7 @@ class PowerFeedBulkImportView(generic.BulkImportView): class PowerFeedBulkEditView(generic.BulkEditView): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') + queryset = PowerFeed.objects.all() filterset = filtersets.PowerFeedFilterSet table = tables.PowerFeedTable form = forms.PowerFeedBulkEditForm @@ -3260,6 +3260,6 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView): class PowerFeedBulkDeleteView(generic.BulkDeleteView): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') + queryset = PowerFeed.objects.all() filterset = filtersets.PowerFeedFilterSet table = tables.PowerFeedTable diff --git a/netbox/extras/views.py b/netbox/extras/views.py index bb99536c3..5b589c181 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -492,14 +492,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView): class JournalEntryBulkEditView(generic.BulkEditView): - queryset = JournalEntry.objects.prefetch_related('created_by') + queryset = JournalEntry.objects.all() filterset = filtersets.JournalEntryFilterSet table = tables.JournalEntryTable form = forms.JournalEntryBulkEditForm class JournalEntryBulkDeleteView(generic.BulkDeleteView): - queryset = JournalEntry.objects.prefetch_related('created_by') + queryset = JournalEntry.objects.all() filterset = filtersets.JournalEntryFilterSet table = tables.JournalEntryTable diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 72b223b55..880ddb83b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -40,11 +40,11 @@ class VRFView(generic.ObjectView): ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count() import_targets_table = tables.RouteTargetTable( - instance.import_targets.prefetch_related('tenant'), + instance.import_targets.all(), orderable=False ) export_targets_table = tables.RouteTargetTable( - instance.export_targets.prefetch_related('tenant'), + instance.export_targets.all(), orderable=False ) @@ -72,14 +72,14 @@ class VRFBulkImportView(generic.BulkImportView): class VRFBulkEditView(generic.BulkEditView): - queryset = VRF.objects.prefetch_related('tenant') + queryset = VRF.objects.all() filterset = filtersets.VRFFilterSet table = tables.VRFTable form = forms.VRFBulkEditForm class VRFBulkDeleteView(generic.BulkDeleteView): - queryset = VRF.objects.prefetch_related('tenant') + queryset = VRF.objects.all() filterset = filtersets.VRFFilterSet table = tables.VRFTable @@ -100,11 +100,11 @@ class RouteTargetView(generic.ObjectView): def get_extra_context(self, request, instance): importing_vrfs_table = tables.VRFTable( - instance.importing_vrfs.prefetch_related('tenant'), + instance.importing_vrfs.all(), orderable=False ) exporting_vrfs_table = tables.VRFTable( - instance.exporting_vrfs.prefetch_related('tenant'), + instance.exporting_vrfs.all(), orderable=False ) @@ -130,14 +130,14 @@ class RouteTargetBulkImportView(generic.BulkImportView): class RouteTargetBulkEditView(generic.BulkEditView): - queryset = RouteTarget.objects.prefetch_related('tenant') + queryset = RouteTarget.objects.all() filterset = filtersets.RouteTargetFilterSet table = tables.RouteTargetTable form = forms.RouteTargetBulkEditForm class RouteTargetBulkDeleteView(generic.BulkDeleteView): - queryset = RouteTarget.objects.prefetch_related('tenant') + queryset = RouteTarget.objects.all() filterset = filtersets.RouteTargetFilterSet table = tables.RouteTargetTable @@ -334,14 +334,14 @@ class AggregateBulkImportView(generic.BulkImportView): class AggregateBulkEditView(generic.BulkEditView): - queryset = Aggregate.objects.prefetch_related('rir') + queryset = Aggregate.objects.all() filterset = filtersets.AggregateFilterSet table = tables.AggregateTable form = forms.AggregateBulkEditForm class AggregateBulkDeleteView(generic.BulkDeleteView): - queryset = Aggregate.objects.prefetch_related('rir') + queryset = Aggregate.objects.all() filterset = filtersets.AggregateFilterSet table = tables.AggregateTable @@ -417,7 +417,7 @@ class PrefixListView(generic.ObjectListView): class PrefixView(generic.ObjectView): - queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role') + queryset = Prefix.objects.all() def get_extra_context(self, request, instance): try: @@ -433,7 +433,7 @@ class PrefixView(generic.ObjectView): ).filter( prefix__net_contains=str(instance.prefix) ).prefetch_related( - 'site', 'role', 'tenant' + 'site', 'role', 'tenant', 'vlan', ) parent_prefix_table = tables.PrefixTable( list(parent_prefixes), @@ -447,7 +447,7 @@ class PrefixView(generic.ObjectView): ).exclude( pk=instance.pk ).prefetch_related( - 'site', 'role' + 'site', 'role', 'tenant', 'vlan', ) duplicate_prefix_table = tables.PrefixTable( list(duplicate_prefixes), @@ -500,7 +500,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', 'tenant__group', + 'tenant__group', ) def get_extra_context(self, request, instance): @@ -519,7 +519,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant') + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') def prep_table_data(self, request, queryset, parent): show_available = bool(request.GET.get('show_available', 'true') == 'true') @@ -552,14 +552,14 @@ class PrefixBulkImportView(generic.BulkImportView): class PrefixBulkEditView(generic.BulkEditView): - queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.prefetch_related('vrf__tenant') filterset = filtersets.PrefixFilterSet table = tables.PrefixTable form = forms.PrefixBulkEditForm class PrefixBulkDeleteView(generic.BulkDeleteView): - queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.prefetch_related('vrf__tenant') filterset = filtersets.PrefixFilterSet table = tables.PrefixTable @@ -611,14 +611,14 @@ class IPRangeBulkImportView(generic.BulkImportView): class IPRangeBulkEditView(generic.BulkEditView): - queryset = IPRange.objects.prefetch_related('vrf', 'tenant') + queryset = IPRange.objects.all() filterset = filtersets.IPRangeFilterSet table = tables.IPRangeTable form = forms.IPRangeBulkEditForm class IPRangeBulkDeleteView(generic.BulkDeleteView): - queryset = IPRange.objects.prefetch_related('vrf', 'tenant') + queryset = IPRange.objects.all() filterset = filtersets.IPRangeFilterSet table = tables.IPRangeTable @@ -789,14 +789,14 @@ class IPAddressBulkImportView(generic.BulkImportView): class IPAddressBulkEditView(generic.BulkEditView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') + queryset = IPAddress.objects.prefetch_related('vrf__tenant') filterset = filtersets.IPAddressFilterSet table = tables.IPAddressTable form = forms.IPAddressBulkEditForm class IPAddressBulkDeleteView(generic.BulkDeleteView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') + queryset = IPAddress.objects.prefetch_related('vrf__tenant') filterset = filtersets.IPAddressFilterSet table = tables.IPAddressTable @@ -819,7 +819,8 @@ class VLANGroupView(generic.ObjectView): def get_extra_context(self, request, instance): vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related( - Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)) + Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)), + 'tenant', 'site', 'role', ).order_by('vid') vlans_count = vlans.count() vlans = add_available_vlans(vlans, vlan_group=instance) @@ -894,7 +895,7 @@ class FHRPGroupView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses ipaddress_table = tables.AssignedIPAddressesTable( - data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=instance.ip_addresses.restrict(request.user, 'view'), orderable=False ) @@ -984,11 +985,11 @@ class VLANListView(generic.ObjectListView): class VLANView(generic.ObjectView): - queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role') + queryset = VLAN.objects.all() def get_extra_context(self, request, instance): prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related( - 'vrf', 'site', 'role' + 'vrf', 'site', 'role', 'tenant' ) prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False) @@ -1046,14 +1047,14 @@ class VLANBulkImportView(generic.BulkImportView): class VLANBulkEditView(generic.BulkEditView): - queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.all() filterset = filtersets.VLANFilterSet table = tables.VLANTable form = forms.VLANBulkEditForm class VLANBulkDeleteView(generic.BulkDeleteView): - queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.all() filterset = filtersets.VLANFilterSet table = tables.VLANTable @@ -1106,14 +1107,14 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView): # class ServiceListView(generic.ObjectListView): - queryset = Service.objects.all() + queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable class ServiceView(generic.ObjectView): - queryset = Service.objects.prefetch_related('ipaddresses') + queryset = Service.objects.all() class ServiceCreateView(generic.ObjectEditView): @@ -1123,7 +1124,7 @@ class ServiceCreateView(generic.ObjectEditView): class ServiceEditView(generic.ObjectEditView): - queryset = Service.objects.prefetch_related('ipaddresses') + queryset = Service.objects.all() form = forms.ServiceForm template_name = 'ipam/service_edit.html' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 07a25b5a4..9a2fe6ab9 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -95,7 +95,7 @@ class TenantListView(generic.ObjectListView): class TenantView(generic.ObjectView): - queryset = Tenant.objects.prefetch_related('group') + queryset = Tenant.objects.all() def get_extra_context(self, request, instance): stats = { @@ -140,14 +140,14 @@ class TenantBulkImportView(generic.BulkImportView): class TenantBulkEditView(generic.BulkEditView): - queryset = Tenant.objects.prefetch_related('group') + queryset = Tenant.objects.all() filterset = filtersets.TenantFilterSet table = tables.TenantTable form = forms.TenantBulkEditForm class TenantBulkDeleteView(generic.BulkDeleteView): - queryset = Tenant.objects.prefetch_related('group') + queryset = Tenant.objects.all() filterset = filtersets.TenantFilterSet table = tables.TenantTable @@ -337,14 +337,14 @@ class ContactBulkImportView(generic.BulkImportView): class ContactBulkEditView(generic.BulkEditView): - queryset = Contact.objects.prefetch_related('group') + queryset = Contact.objects.all() filterset = filtersets.ContactFilterSet table = tables.ContactTable form = forms.ContactBulkEditForm class ContactBulkDeleteView(generic.BulkDeleteView): - queryset = Contact.objects.prefetch_related('group') + queryset = Contact.objects.all() filterset = filtersets.ContactFilterSet table = tables.ContactTable diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4cd7da30d..5b26f8503 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -209,14 +209,14 @@ class ClusterBulkImportView(generic.BulkImportView): class ClusterBulkEditView(generic.BulkEditView): - queryset = Cluster.objects.prefetch_related('type', 'group', 'site') + queryset = Cluster.objects.all() filterset = filtersets.ClusterFilterSet table = tables.ClusterTable form = forms.ClusterBulkEditForm class ClusterBulkDeleteView(generic.BulkDeleteView): - queryset = Cluster.objects.prefetch_related('type', 'group', 'site') + queryset = Cluster.objects.all() filterset = filtersets.ClusterFilterSet table = tables.ClusterTable @@ -308,7 +308,7 @@ class ClusterRemoveDevicesView(generic.ObjectEditView): # class VirtualMachineListView(generic.ObjectListView): - queryset = VirtualMachine.objects.all() + queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6') filterset = filtersets.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineTable @@ -334,7 +334,8 @@ class VirtualMachineView(generic.ObjectView): services = Service.objects.restrict(request.user, 'view').filter( virtual_machine=instance ).prefetch_related( - Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)) + Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)), + 'virtual_machine' ) return { @@ -383,14 +384,14 @@ class VirtualMachineBulkImportView(generic.BulkImportView): class VirtualMachineBulkEditView(generic.BulkEditView): - queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') + queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6') filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm class VirtualMachineBulkDeleteView(generic.BulkDeleteView): - queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') + queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6') filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable @@ -413,7 +414,7 @@ class VMInterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses ipaddress_table = AssignedIPAddressesTable( - data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=instance.ip_addresses.restrict(request.user, 'view'), orderable=False ) From 262a0cf397e1bf2cee4d11567b747c163161a838 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 1 Aug 2022 13:29:39 -0400 Subject: [PATCH 195/245] Fixes #9789: Fix rendering of cable traces ending at provider networks --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/api/views.py | 18 +++++++++--------- netbox/dcim/models/device_components.py | 11 +++++++---- netbox/dcim/svg/cables.py | 6 ++++-- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 68cff0547..e30ff011c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -104,6 +104,7 @@ Custom field UI visibility has no impact on API operation. * [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form * [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables * [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view +* [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks * [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination * [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination * [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 59445d97b..32cc3dbba 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -64,20 +64,20 @@ class PathEndpointMixin(object): return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml') # Serialize path objects, iterating over each three-tuple in the path - for near_end, cable, far_end in obj.trace(): - if near_end is not None: - serializer_a = get_serializer_for_model(near_end[0], prefix=NESTED_SERIALIZER_PREFIX) - near_end = serializer_a(near_end, many=True, context={'request': request}).data + for near_ends, cable, far_ends in obj.trace(): + if near_ends: + serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX) + near_ends = serializer_a(near_ends, many=True, context={'request': request}).data else: # Path is split; stop here break - if cable is not None: + if cable: cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data - if far_end is not None: - serializer_b = get_serializer_for_model(far_end[0], prefix=NESTED_SERIALIZER_PREFIX) - far_end = serializer_b(far_end, many=True, context={'request': request}).data + if far_ends: + serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX) + far_ends = serializer_b(far_ends, many=True, context={'request': request}).data - path.append((near_end, cable, far_end)) + path.append((near_ends, cable, far_ends)) return Response(path) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8f62b0626..5e2fc348e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -212,10 +212,13 @@ class PathEndpoint(models.Model): break path.extend(origin._path.path_objects) - while (len(path)) % 3: - # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort) - # by inserting empty entries immediately prior to the path's destination node(s) - path.append([]) + + # If the path ends at a non-connected pass-through port, pad out the link and far-end terminations + if len(path) % 3 == 1: + path.extend(([], [])) + # If the path ends at a site or provider network, inject a null "link" to render an attachment + elif len(path) % 3 == 2: + path.insert(-1, []) # Check for a bridged relationship to continue the trace destinations = origin._path.destinations diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index f9c614b67..3b259eca2 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -369,14 +369,16 @@ class CableTraceSVG: parent_objects = set(end.parent_object for end in far_ends) self.draw_parent_objects(parent_objects) + # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with + # a CircuitTermination) elif far_ends: # Attachment attachment = self.draw_attachment() self.connectors.append(attachment) - # ProviderNetwork - self.draw_parent_objects(set(end.parent_object for end in far_ends)) + # Object + self.draw_parent_objects(far_ends) # Determine drawing size self.drawing = svgwrite.Drawing( From 29a611c7293741100f3e5351243dffbae8e5b481 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 1 Aug 2022 16:51:44 -0400 Subject: [PATCH 196/245] Closes #9896: Discontinue arbitrary use of OrderedDict --- netbox/dcim/api/views.py | 3 +- netbox/dcim/views.py | 44 +++++++++++----------- netbox/extras/reports.py | 17 ++++----- netbox/extras/scripts.py | 5 +-- netbox/extras/templatetags/custom_links.py | 4 +- netbox/ipam/api/serializers.py | 32 ++++++++-------- netbox/netbox/api/fields.py | 10 ++--- netbox/netbox/api/views.py | 25 ++++++------ netbox/utilities/utils.py | 3 +- 9 files changed, 65 insertions(+), 78 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 32cc3dbba..c18eab01f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,5 +1,4 @@ import socket -from collections import OrderedDict from django.http import Http404, HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 @@ -484,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): return HttpResponseForbidden() napalm_methods = request.GET.getlist('method') - response = OrderedDict([(m, None) for m in napalm_methods]) + response = {m: None for m in napalm_methods} config = get_config() username = config.NAPALM_USERNAME diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6daecb3a6..4480bee6e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger @@ -945,18 +943,18 @@ class DeviceTypeImportView(generic.ObjectImportView): ] queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm - related_object_forms = OrderedDict(( - ('console-ports', forms.ConsolePortTemplateImportForm), - ('console-server-ports', forms.ConsoleServerPortTemplateImportForm), - ('power-ports', forms.PowerPortTemplateImportForm), - ('power-outlets', forms.PowerOutletTemplateImportForm), - ('interfaces', forms.InterfaceTemplateImportForm), - ('rear-ports', forms.RearPortTemplateImportForm), - ('front-ports', forms.FrontPortTemplateImportForm), - ('module-bays', forms.ModuleBayTemplateImportForm), - ('device-bays', forms.DeviceBayTemplateImportForm), - ('inventory-items', forms.InventoryItemTemplateImportForm), - )) + related_object_forms = { + 'console-ports': forms.ConsolePortTemplateImportForm, + 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, + 'power-ports': forms.PowerPortTemplateImportForm, + 'power-outlets': forms.PowerOutletTemplateImportForm, + 'interfaces': forms.InterfaceTemplateImportForm, + 'rear-ports': forms.RearPortTemplateImportForm, + 'front-ports': forms.FrontPortTemplateImportForm, + 'module-bays': forms.ModuleBayTemplateImportForm, + 'device-bays': forms.DeviceBayTemplateImportForm, + 'inventory-items': forms.InventoryItemTemplateImportForm, + } def prep_related_object_data(self, parent, data): data.update({'device_type': parent}) @@ -1075,15 +1073,15 @@ class ModuleTypeImportView(generic.ObjectImportView): ] queryset = ModuleType.objects.all() model_form = forms.ModuleTypeImportForm - related_object_forms = OrderedDict(( - ('console-ports', forms.ConsolePortTemplateImportForm), - ('console-server-ports', forms.ConsoleServerPortTemplateImportForm), - ('power-ports', forms.PowerPortTemplateImportForm), - ('power-outlets', forms.PowerOutletTemplateImportForm), - ('interfaces', forms.InterfaceTemplateImportForm), - ('rear-ports', forms.RearPortTemplateImportForm), - ('front-ports', forms.FrontPortTemplateImportForm), - )) + related_object_forms = { + 'console-ports': forms.ConsolePortTemplateImportForm, + 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, + 'power-ports': forms.PowerPortTemplateImportForm, + 'power-outlets': forms.PowerOutletTemplateImportForm, + 'interfaces': forms.InterfaceTemplateImportForm, + 'rear-ports': forms.RearPortTemplateImportForm, + 'front-ports': forms.FrontPortTemplateImportForm, + } def prep_related_object_data(self, parent, data): data.update({'module_type': parent}) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 0a8a8d89b..43d916aff 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -3,7 +3,6 @@ import inspect import logging import pkgutil import traceback -from collections import OrderedDict from django.conf import settings from django.utils import timezone @@ -114,7 +113,7 @@ class Report(object): def __init__(self): - self._results = OrderedDict() + self._results = {} self.active_test = None self.failed = False @@ -125,13 +124,13 @@ class Report(object): for method in dir(self): if method.startswith('test_') and callable(getattr(self, method)): test_methods.append(method) - self._results[method] = OrderedDict([ - ('success', 0), - ('info', 0), - ('warning', 0), - ('failure', 0), - ('log', []), - ]) + self._results[method] = { + 'success': 0, + 'info': 0, + 'warning': 0, + 'failure': 0, + 'log': [], + } if not test_methods: raise Exception("A report must contain at least one test method.") self.test_methods = test_methods diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index cee264878..6e4478304 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -6,7 +6,6 @@ import pkgutil import sys import traceback import threading -from collections import OrderedDict import yaml from django import forms @@ -496,7 +495,7 @@ def get_scripts(use_names=False): Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human- defined name in place of the actual module name. """ - scripts = OrderedDict() + scripts = {} # Iterate through all modules within the scripts path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): @@ -510,7 +509,7 @@ def get_scripts(use_names=False): if use_names and hasattr(module, 'name'): module_name = module.name - module_scripts = OrderedDict() + module_scripts = {} script_order = getattr(module, "script_order", ()) ordered_scripts = [cls for cls in script_order if is_script(cls)] unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order] diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index d963bd25a..a73eb3fb4 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django import template from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe @@ -50,7 +48,7 @@ def custom_links(context, obj): 'perms': context['perms'], # django.contrib.auth.context_processors.auth } template_code = '' - group_names = OrderedDict() + group_names = {} for cl in custom_links: diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index b3a3589fd..91a81d3b2 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -227,13 +225,13 @@ class AvailableVLANSerializer(serializers.Serializer): group = NestedVLANGroupSerializer(read_only=True) def to_representation(self, instance): - return OrderedDict([ - ('vid', instance), - ('group', NestedVLANGroupSerializer( + return { + 'vid': instance, + 'group': NestedVLANGroupSerializer( self.context['group'], context={'request': self.context['request']} - ).data), - ]) + ).data, + } class CreateAvailableVLANSerializer(NetBoxModelSerializer): @@ -318,11 +316,11 @@ class AvailablePrefixSerializer(serializers.Serializer): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data else: vrf = None - return OrderedDict([ - ('family', instance.version), - ('prefix', str(instance)), - ('vrf', vrf), - ]) + return { + 'family': instance.version, + 'prefix': str(instance), + 'vrf': vrf, + } # @@ -397,11 +395,11 @@ class AvailableIPSerializer(serializers.Serializer): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data else: vrf = None - return OrderedDict([ - ('family', self.context['parent'].family), - ('address', f"{instance}/{self.context['parent'].mask_length}"), - ('vrf', vrf), - ]) + return { + 'family': self.context['parent'].family, + 'address': f"{instance}/{self.context['parent'].mask_length}", + 'vrf': vrf, + } # diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 1f3c40dc2..52343c2f6 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.core.exceptions import ObjectDoesNotExist from netaddr import IPNetwork from rest_framework import serializers @@ -48,10 +46,10 @@ class ChoiceField(serializers.Field): def to_representation(self, obj): if obj == '': return None - return OrderedDict([ - ('value', obj), - ('label', self._choices[obj]) - ]) + return { + 'value': obj, + 'label': self._choices[obj], + } def to_internal_value(self, data): if data == '': diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 835ebc6a9..6c6083959 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -1,5 +1,4 @@ import platform -from collections import OrderedDict from django import __version__ as DJANGO_VERSION from django.apps import apps @@ -26,18 +25,18 @@ class APIRootView(APIView): def get(self, request, format=None): - return Response(OrderedDict(( - ('circuits', reverse('circuits-api:api-root', request=request, format=format)), - ('dcim', reverse('dcim-api:api-root', request=request, format=format)), - ('extras', reverse('extras-api:api-root', request=request, format=format)), - ('ipam', reverse('ipam-api:api-root', request=request, format=format)), - ('plugins', reverse('plugins-api:api-root', request=request, format=format)), - ('status', reverse('api-status', request=request, format=format)), - ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), - ('users', reverse('users-api:api-root', request=request, format=format)), - ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), - ('wireless', reverse('wireless-api:api-root', request=request, format=format)), - ))) + return Response({ + 'circuits': reverse('circuits-api:api-root', request=request, format=format), + 'dcim': reverse('dcim-api:api-root', request=request, format=format), + 'extras': reverse('extras-api:api-root', request=request, format=format), + 'ipam': reverse('ipam-api:api-root', request=request, format=format), + 'plugins': reverse('plugins-api:api-root', request=request, format=format), + 'status': reverse('api-status', request=request, format=format), + 'tenancy': reverse('tenancy-api:api-root', request=request, format=format), + 'users': reverse('users-api:api-root', request=request, format=format), + 'virtualization': reverse('virtualization-api:api-root', request=request, format=format), + 'wireless': reverse('wireless-api:api-root', request=request, format=format), + }) class StatusView(APIView): diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index fa0534ec0..1dece76c8 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,7 +1,6 @@ import datetime import decimal import json -from collections import OrderedDict from decimal import Decimal from itertools import count, groupby @@ -218,7 +217,7 @@ def deepmerge(original, new): """ Deep merge two dictionaries (new into original) and return a new dict """ - merged = OrderedDict(original) + merged = dict(original) for key, val in new.items(): if key in original and isinstance(original[key], dict) and val and isinstance(val, dict): merged[key] = deepmerge(original[key], val) From e96620260a6c1b5cf8cff2112d40d061984a7b2c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Aug 2022 13:49:34 -0400 Subject: [PATCH 197/245] Closes #9903: Implement a mechanism for automatically updating denormalized fields --- docs/release-notes/version-3.3.md | 1 + netbox/extras/registry.py | 1 + netbox/netbox/denormalized.py | 54 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 netbox/netbox/denormalized.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index e30ff011c..49d6891e2 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -124,6 +124,7 @@ Custom field UI visibility has no impact on API operation. * [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset * [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output +* [#9903](https://github.com/netbox-community/netbox/issues/9903) - Implement a mechanism for automatically updating denormalized fields ### REST API Changes diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index 07fd4cc24..e1437c00e 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -28,3 +28,4 @@ registry = Registry() registry['model_features'] = { feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES } +registry['denormalized_fields'] = collections.defaultdict(list) diff --git a/netbox/netbox/denormalized.py b/netbox/netbox/denormalized.py new file mode 100644 index 000000000..5808acddc --- /dev/null +++ b/netbox/netbox/denormalized.py @@ -0,0 +1,54 @@ +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from extras.registry import registry + + +logger = logging.getLogger('netbox.denormalized') + + +def register(model, field_name, mappings): + """ + Register a denormalized model field to ensure that it is kept up-to-date with the related object. + + Args: + model: The class being updated + field_name: The name of the field related to the triggering instance + mappings: Dictionary mapping of local to remote fields + """ + logger.debug(f'Registering denormalized field {model}.{field_name}') + + field = model._meta.get_field(field_name) + rel_model = field.related_model + + registry['denormalized_fields'][rel_model].append( + (model, field_name, mappings) + ) + + +@receiver(post_save) +def update_denormalized_fields(sender, instance, created, raw, **kwargs): + """ + Check if the sender has denormalized fields registered, and update them as necessary. + """ + # Skip for new objects or those being populated from raw data + if created or raw: + return + + # Look up any denormalized fields referencing this model from the application registry + for model, field_name, mappings in registry['denormalized_fields'].get(sender, []): + logger.debug(f'Updating denormalized values for {model}.{field_name}') + filter_params = { + field_name: instance.pk, + } + update_params = { + # Map the denormalized field names to the instance's values + denorm: getattr(instance, origin) for denorm, origin in mappings.items() + } + + # TODO: Improve efficiency here by placing conditions on the query? + # Update all the denormalized fields with the triggering object's new values + count = model.objects.filter(**filter_params).update(**update_params) + logger.debug(f'Updated {count} rows') From 5b3ef045506100c6fdf6ae9a15d7f717a1fd0f15 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Aug 2022 12:38:16 -0500 Subject: [PATCH 198/245] #9888 - Add filter and columns for device and site --- netbox/ipam/filtersets.py | 83 +++++++++++++++++++++++++++------ netbox/ipam/forms/filtersets.py | 44 ++++++++++++++++- netbox/ipam/models/l2vpn.py | 15 ++++++ netbox/ipam/tables/l2vpn.py | 11 ++++- 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index edd1867ed..132094325 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -980,21 +980,65 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): to_field_name='slug', label='L2VPN (slug)', ) - device = MultiValueCharFilter( - method='filter_device', - field_name='name', - label='Device (name)', + region = MultiValueCharFilter( + method='filter_region', + field_name='slug', + label='Region (slug)', ) - device_id = MultiValueNumberFilter( - method='filter_device', + region_id = MultiValueNumberFilter( + method='filter_region', + field_name='pk', + label='Region (ID)', + ) + site = MultiValueCharFilter( + method='filter_site', + field_name='slug', + label='Device (slug)', + ) + site_id = MultiValueNumberFilter( + method='filter_site', field_name='pk', label='Device (ID)', ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (name)', + ) interface_id = django_filters.ModelMultipleChoiceFilter( field_name='interface', queryset=Interface.objects.all(), label='Interface (ID)', ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.all(), + to_field_name='name', + label='VM interface (name)', + ) vminterface_id = django_filters.ModelMultipleChoiceFilter( field_name='vminterface', queryset=VMInterface.objects.all(), @@ -1027,13 +1071,22 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): qs_filter = Q(l2vpn__name__icontains=value) return queryset.filter(qs_filter) - def filter_device(self, queryset, name, value): - devices = Device.objects.filter(**{'{}__in'.format(name): value}) - if not devices.exists(): - return queryset.none() - interface_ids = [] - for device in devices: - interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) - return queryset.filter( - interface__in=interface_ids + def filter_site(self, queryset, name, value): + qs = queryset.filter( + Q( + Q(**{'vlan__site__{}__in'.format(name): value}) | + Q(**{'interface__device__site__{}__in'.format(name): value}) | + Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value}) + ) ) + return qs + + def filter_region(self, queryset, name, value): + qs = queryset.filter( + Q( + Q(**{'vlan__site__region__{}__in'.format(name): value}) | + Q(**{'interface__device__site__region__{}__in'.format(name): value}) | + Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value}) + ) + ) + return qs diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 384a4da33..d93bd16d0 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -508,7 +508,8 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('l2vpn_id', 'assigned_object_type_id')), + (None, ('l2vpn_id', 'assigned_object_type_id', )), + ('Assigned Object', ('region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), ) l2vpn_id = DynamicModelChoiceField( queryset=L2VPN.objects.all(), @@ -520,3 +521,44 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): required=False, label='Object type' ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region_id' + }, + label=_('Site') + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Device') + ) + vlan_id = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('VLAN') + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Virtual Machine') + ) diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 5d85fe915..5adf5e05d 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -113,3 +113,18 @@ class L2VPNTermination(NetBoxModel): f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already ' f'defined.' ) + + @property + def assigned_object_parent(self): + obj_type = ContentType.objects.get_for_model(self.assigned_object) + if obj_type.model == 'vminterface': + return self.assigned_object.virtual_machine + elif obj_type.model == 'interface': + return self.assigned_object.device + elif obj_type.model == 'vminterface': + return self.assigned_object.virtual_machine + return None + + @property + def assigned_object_site(self): + return self.assigned_object_parent.site diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 5be525343..e2eae7a32 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -53,8 +53,17 @@ class L2VPNTerminationTable(NetBoxTable): linkify=True, orderable=False ) + assigned_object_parent = tables.Column( + linkify=True, + orderable=False + ) + assigned_object_site = tables.Column( + linkify=True, + orderable=False + ) class Meta(NetBoxTable.Meta): model = L2VPNTermination - fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions') + fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', + 'assigned_object_site', 'actions') default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions') From d3a567a2f565097d7409d888dacabda2c59f42f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Aug 2022 13:56:52 -0400 Subject: [PATCH 199/245] Fixes #9788: Ensure denormalized fields on CableTermination are kept in sync with related objects --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/apps.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 49d6891e2..5d043a777 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -104,6 +104,7 @@ Custom field UI visibility has no impact on API operation. * [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form * [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables * [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view +* [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects * [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks * [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination * [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 78a243f84..4be2df659 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -1,10 +1,26 @@ from django.apps import AppConfig +from netbox import denormalized + class DCIMConfig(AppConfig): name = "dcim" verbose_name = "DCIM" def ready(self): - import dcim.signals + from .models import CableTermination + + # Register denormalized fields + denormalized.register(CableTermination, '_device', { + '_rack': 'rack', + '_location': 'location', + '_site': 'site', + }) + denormalized.register(CableTermination, '_rack', { + '_location': 'location', + '_site': 'site', + }) + denormalized.register(CableTermination, '_location', { + '_site': 'site', + }) From 37c4f1a7d3509f137a5a81d1b570d10a7533ccae Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Aug 2022 13:38:17 -0500 Subject: [PATCH 200/245] Fix up a few minor mistakes. Add tests. --- netbox/ipam/filtersets.py | 4 ++-- netbox/ipam/forms/filtersets.py | 11 ++++++----- netbox/ipam/tests/test_filtersets.py | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 132094325..49ec15fc1 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -993,12 +993,12 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): site = MultiValueCharFilter( method='filter_site', field_name='slug', - label='Device (slug)', + label='Site (slug)', ) site_id = MultiValueNumberFilter( method='filter_site', field_name='pk', - label='Device (ID)', + label='Site (ID)', ) device = django_filters.ModelMultipleChoiceFilter( field_name='interface__device__name', diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index d93bd16d0..ecf63b49f 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple, ) from virtualization.models import VirtualMachine @@ -508,8 +508,8 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - (None, ('l2vpn_id', 'assigned_object_type_id', )), - ('Assigned Object', ('region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), + (None, ('l2vpn_id', )), + ('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), ) l2vpn_id = DynamicModelChoiceField( queryset=L2VPN.objects.all(), @@ -517,9 +517,10 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): label='L2VPN' ) assigned_object_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), + queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS), required=False, - label='Object type' + label=_('Assigned Object Type'), + limit_choices_to=L2VPN_ASSIGNMENT_MODELS ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 9106a4965..081f6e11d 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1600,3 +1600,24 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'vlan': ['VLAN 1', 'VLAN 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + site = Site.objects.all().first() + params = {'site_id': [site.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'site': ['site-1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_device(self): + device = Device.objects.all().first() + params = {'device_id': [device.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'device': ['Device 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_virtual_machine(self): + virtual_machine = VirtualMachine.objects.all().first() + params = {'virtual_machine_id': [virtual_machine.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'virtual_machine': ['Virtual Machine 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) From b9678c7c0e50c9b2f24aec95a0d8c13c1b20f3dd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Aug 2022 10:50:35 -0400 Subject: [PATCH 201/245] Closes #9853: Show full object in cable A/B termination lists --- netbox/netbox/api/serializers/generic.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py index 8b4069c98..5016bdaab 100644 --- a/netbox/netbox/api/serializers/generic.py +++ b/netbox/netbox/api/serializers/generic.py @@ -1,7 +1,10 @@ from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from netbox.api.fields import ContentTypeField +from netbox.constants import NESTED_SERIALIZER_PREFIX +from utilities.api import get_serializer_for_model from utilities.utils import content_type_identifier __all__ = ( @@ -17,6 +20,7 @@ class GenericObjectSerializer(serializers.Serializer): queryset=ContentType.objects.all() ) object_id = serializers.IntegerField() + object = serializers.SerializerMethodField(read_only=True) def to_internal_value(self, data): data = super().to_internal_value(data) @@ -25,7 +29,17 @@ class GenericObjectSerializer(serializers.Serializer): def to_representation(self, instance): ct = ContentType.objects.get_for_model(instance) - return { + data = { 'object_type': content_type_identifier(ct), 'object_id': instance.pk, } + if 'request' in self.context: + data['object'] = self.get_object(instance) + + return data + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_object(self, obj): + serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX) + # context = {'request': self.context['request']} + return serializer(obj, context=self.context).data From 367bf25618d1be55c10b7e707101f2759711e855 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Aug 2022 12:46:16 -0400 Subject: [PATCH 202/245] Fixes #9778: Fix exception during cable deletion after deleting a connected termination --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/models/cables.py | 24 +++++++++++++----------- netbox/dcim/signals.py | 5 ++++- netbox/dcim/svg/cables.py | 5 ++++- netbox/dcim/utils.py | 5 +++-- netbox/templates/dcim/interface.html | 2 +- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 5d043a777..6099e8a61 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -104,6 +104,7 @@ Custom field UI visibility has no impact on API operation. * [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form * [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables * [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view +* [#9778](https://github.com/netbox-community/netbox/issues/9778) - Fix exception during cable deletion after deleting a connected termination * [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects * [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks * [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e0a489f5b..321d808ff 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -431,11 +431,7 @@ class CablePath(models.Model): """ Return the list of originating objects. """ - if hasattr(self, '_path_objects'): - return self.path_objects[0] - return [ - path_node_to_object(node) for node in self.path[0] - ] + return self.path_objects[0] @property def destinations(self): @@ -444,11 +440,7 @@ class CablePath(models.Model): """ if not self.is_complete: return [] - if hasattr(self, '_path_objects'): - return self.path_objects[-1] - return [ - path_node_to_object(node) for node in self.path[-1] - ] + return self.path_objects[-1] @property def segment_count(self): @@ -463,6 +455,9 @@ class CablePath(models.Model): """ from circuits.models import CircuitTermination + if not terminations: + return None + # Ensure all originating terminations are attached to the same link if len(terminations) > 1: assert all(t.link == terminations[0].link for t in terminations[1:]) @@ -529,6 +524,9 @@ class CablePath(models.Model): ]) # Step 6: Determine the "next hop" terminations, if applicable + if not remote_terminations: + break + if isinstance(remote_terminations[0], FrontPort): # Follow FrontPorts to their corresponding RearPorts rear_ports = RearPort.objects.filter( @@ -640,7 +638,11 @@ class CablePath(models.Model): nodes = [] for node in step: ct_id, object_id = decompile_path_node(node) - nodes.append(prefetched[ct_id][object_id]) + try: + nodes.append(prefetched[ct_id][object_id]) + except KeyError: + # Ignore stale (deleted) object IDs + pass path.append(nodes) return path diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 2293f8840..b990daf1a 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -116,7 +116,10 @@ def retrace_cable_paths(instance, **kwargs): @receiver(post_delete, sender=CableTermination) def nullify_connected_endpoints(instance, **kwargs): """ - Disassociate the Cable from the termination object. + Disassociate the Cable from the termination object, and retrace any affected CablePaths. """ model = instance.termination_type.model_class() model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='') + + for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable): + cablepath.retrace() diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 3b259eca2..26d16fafe 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -362,8 +362,11 @@ class CableTraceSVG: terminations = self.draw_terminations(far_ends) for term in terminations: self.draw_fanout(term, cable) - else: + elif far_ends: self.draw_terminations(far_ends) + else: + # Link is not connected to anything + break # Far end parent parent_objects = set(end.parent_object for end in far_ends) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 26b6e2e25..eadd2da96 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -24,11 +24,12 @@ def object_to_path_node(obj): def path_node_to_object(repr): """ - Given the string representation of a path node, return the corresponding instance. + Given the string representation of a path node, return the corresponding instance. If the object no longer + exists, return None. """ ct_id, object_id = decompile_path_node(repr) ct = ContentType.objects.get_for_id(ct_id) - return ct.model_class().objects.get(pk=object_id) + return ct.model_class().objects.filter(pk=object_id).first() def create_cablepath(terminations): diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3a7fe986a..11e776872 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -219,7 +219,7 @@
    Path Status - {% if object.path.is_active %} + {% if object.path.is_complete and object.path.is_active %} Reachable {% else %} Not Reachable From f11a6f0135fc40bb2b695f38b5668a09ec46e3be Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 3 Aug 2022 14:41:50 -0400 Subject: [PATCH 203/245] Release v3.3-beta2 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 10 +++++----- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 88fbb1df9..dc8dd8275 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.3-beta1 + placeholder: v3.3-beta2 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1035c02fb..d9e5a26fd 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.3-beta1 + placeholder: v3.3-beta2 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6099e8a61..2a3935e5e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,6 +1,6 @@ # NetBox v3.3 -## v3.3.0 (FUTURE) +## v3.3-beta2 (2022-08-03) ### Breaking Changes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e0ec8e1ec..0dcde9cf6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3-beta1' +VERSION = '3.3-beta2' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 4c8e5e5ce..8a7dd79d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ bleach==5.0.1 -Django==4.0.6 +Django==4.0.7 django-cors-headers==3.13.0 django-debug-toolbar==3.5.0 django-filter==22.1 @@ -14,19 +14,19 @@ django-tables2==2.4.1 django-taggit==3.0.0 django-timezone-field==5.0 djangorestframework==3.13.1 -drf-yasg[validation]==1.20.0 +drf-yasg[validation]==1.21.3 graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 -Markdown==3.3.7 -markdown-include==0.6.0 +Markdown==3.4.1 +markdown-include==0.7.0 mkdocs-material==8.3.9 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.2.0 psycopg2-binary==2.9.3 PyYAML==6.0 -sentry-sdk==1.7.1 +sentry-sdk==1.9.0 social-auth-app-django==5.0.0 social-auth-core==4.3.0 svgwrite==1.4.3 From 56433cf85520edee9cf0d440bfeb82524eac7fc7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Aug 2022 10:42:44 -0400 Subject: [PATCH 204/245] Fix denormalization logic to be compatible with loading fixture data --- netbox/netbox/denormalized.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/denormalized.py b/netbox/netbox/denormalized.py index 5808acddc..cd4a869d2 100644 --- a/netbox/netbox/denormalized.py +++ b/netbox/netbox/denormalized.py @@ -33,6 +33,10 @@ def update_denormalized_fields(sender, instance, created, raw, **kwargs): """ Check if the sender has denormalized fields registered, and update them as necessary. """ + def _get_field_value(instance, field_name): + field = instance._meta.get_field(field_name) + return field.value_from_object(instance) + # Skip for new objects or those being populated from raw data if created or raw: return @@ -45,7 +49,7 @@ def update_denormalized_fields(sender, instance, created, raw, **kwargs): } update_params = { # Map the denormalized field names to the instance's values - denorm: getattr(instance, origin) for denorm, origin in mappings.items() + denorm: _get_field_value(instance, origin) for denorm, origin in mappings.items() } # TODO: Improve efficiency here by placing conditions on the query? From 5da3cab4de0469c8e8f19726600286abc590c7c3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Aug 2022 14:11:52 -0400 Subject: [PATCH 205/245] Reorganize documentation --- docs/additional-features/webhooks.md | 57 ----------- docs/administration/permissions.md | 71 ++++++++++++- docs/configuration/miscellaneous.md | 2 +- docs/configuration/napalm.md | 2 +- docs/customization/custom-fields.md | 75 +++++++++++++- .../custom-links.md} | 0 docs/customization/export-templates.md | 39 +++++++- docs/development/models.md | 8 +- .../change-logging.md | 0 docs/features/circuits.md | 3 + docs/features/contacts.md | 3 + .../context-data.md} | 0 docs/features/customization.md | 3 + docs/features/devices-cabling.md | 3 + docs/features/facilities.md | 3 + docs/features/ipam.md | 3 + .../journaling.md | 0 docs/features/l2vpn-overlay.md | 3 + docs/features/permissions.md | 3 + docs/features/power-tracking.md | 3 + docs/features/services.md | 3 + docs/features/sso.md | 3 + .../extras/tag.md => features/tags.md} | 0 docs/features/tenancy.md | 3 + docs/features/vlan-management.md | 3 + .../webhook.md => features/webhooks.md} | 56 +++++++++++ docs/features/wireless.md | 3 + docs/getting-started/populating-data.md | 2 +- docs/installation/3-netbox.md | 2 +- .../graphql-api.md} | 0 .../napalm.md | 0 .../prometheus-metrics.md | 0 .../overview.md => integrations/rest-api.md} | 99 ++++++++++++++++++- docs/models/extras/customfield.md | 73 -------------- docs/models/extras/exporttemplate.md | 37 ------- docs/models/extras/imageattachment.md | 3 - docs/models/users/objectpermission.md | 69 ------------- docs/models/users/token.md | 19 ---- docs/{rest-api => reference}/filtering.md | 0 docs/release-notes/version-3.0.md | 2 +- docs/rest-api/authentication.md | 73 -------------- mkdocs.yml | 47 +++++---- 42 files changed, 412 insertions(+), 366 deletions(-) delete mode 100644 docs/additional-features/webhooks.md rename docs/{models/extras/customlink.md => customization/custom-links.md} (100%) rename docs/{additional-features => features}/change-logging.md (100%) create mode 100644 docs/features/circuits.md create mode 100644 docs/features/contacts.md rename docs/{models/extras/configcontext.md => features/context-data.md} (100%) create mode 100644 docs/features/customization.md create mode 100644 docs/features/devices-cabling.md create mode 100644 docs/features/facilities.md create mode 100644 docs/features/ipam.md rename docs/{additional-features => features}/journaling.md (100%) create mode 100644 docs/features/l2vpn-overlay.md create mode 100644 docs/features/permissions.md create mode 100644 docs/features/power-tracking.md create mode 100644 docs/features/services.md create mode 100644 docs/features/sso.md rename docs/{models/extras/tag.md => features/tags.md} (100%) create mode 100644 docs/features/tenancy.md create mode 100644 docs/features/vlan-management.md rename docs/{models/extras/webhook.md => features/webhooks.md} (67%) create mode 100644 docs/features/wireless.md rename docs/{graphql-api/overview.md => integrations/graphql-api.md} (100%) rename docs/{additional-features => integrations}/napalm.md (100%) rename docs/{additional-features => integrations}/prometheus-metrics.md (100%) rename docs/{rest-api/overview.md => integrations/rest-api.md} (78%) delete mode 100644 docs/models/extras/customfield.md delete mode 100644 docs/models/extras/exporttemplate.md delete mode 100644 docs/models/extras/imageattachment.md delete mode 100644 docs/models/users/objectpermission.md delete mode 100644 docs/models/users/token.md rename docs/{rest-api => reference}/filtering.md (100%) delete mode 100644 docs/rest-api/authentication.md diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md deleted file mode 100644 index 5077f3a68..000000000 --- a/docs/additional-features/webhooks.md +++ /dev/null @@ -1,57 +0,0 @@ -{!models/extras/webhook.md!} - -## Conditional Webhooks - -A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active": - -```json -{ - "and": [ - { - "attr": "status.value", - "value": "active" - } - ] -} -``` - -For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md). - -## Webhook Processing - -When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks. - -A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI. - -## Troubleshooting - -To assist with verifying that the content of outgoing webhooks is rendered correctly, NetBox provides a simple HTTP listener that can be run locally to receive and display webhook requests. First, modify the target URL of the desired webhook to `http://localhost:9000/`. This will instruct NetBox to send the request to the local server on TCP port 9000. Then, start the webhook receiver service from the NetBox root directory: - -```no-highlight -$ python netbox/manage.py webhook_receiver -Listening on port http://localhost:9000. Stop with CONTROL-C. -``` - -You can test the receiver itself by sending any HTTP request to it. For example: - -```no-highlight -$ curl -X POST http://localhost:9000 --data '{"foo": "bar"}' -``` - -The server will print output similar to the following: - -```no-highlight -[1] Tue, 07 Apr 2020 17:44:02 GMT 127.0.0.1 "POST / HTTP/1.1" 200 - -Host: localhost:9000 -User-Agent: curl/7.58.0 -Accept: */* -Content-Length: 14 -Content-Type: application/x-www-form-urlencoded - -{"foo": "bar"} ------------- -``` - -Note that `webhook_receiver` does not actually _do_ anything with the information received: It merely prints the request headers and body for inspection. - -Now, when the NetBox webhook is triggered and processed, you should see its headers and content appear in the terminal where the webhook receiver is listening. If you don't, check that the `rqworker` process is running and that webhook events are being placed into the queue (visible under the NetBox admin UI). diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index 60717c28a..21f259979 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -1,8 +1,75 @@ -# Permissions +# Object-Based Permissions NetBox v2.9 introduced a new object-based permissions framework, which replaces Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. -{!models/users/objectpermission.md!} +A permission in NetBox represents a relationship shared by several components: + +* Object type(s) - One or more types of object in NetBox +* User(s)/Group(s) - One or more users or groups of users +* Action(s) - The action(s) that can be performed on an object +* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects + +At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). + +## Actions + +There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): + +* **View** - Retrieve an object from the database +* **Add** - Create a new object +* **Change** - Modify an existing object +* **Delete** - Delete an existing object + +In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. + +!!! note + Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`. + +## Constraints + +Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. + +All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints. + +```json +{ + "status": "active", + "region__name": "Americas" +} +``` + +The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. + +To achieve a logical OR with a different set of constraints, define multiple objects within a list. For example, if you want to constrain the permission to VLANs with an ID between 100 and 199 _or_ a status of "reserved," do the following: + +```json +[ + { + "vid__gte": 100, + "vid__lt": 200 + }, + { + "status": "reserved" + } +] +``` + +Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. + +### User Token + +!!! info "This feature was introduced in NetBox v3.3" + +When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as: + +```json +{ + "created_by": "$user" +} +``` + +The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. + #### Example Constraint Definitions diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 2aa21b7e5..614e90eac 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -127,7 +127,7 @@ A web user or API consumer can request an arbitrary number of objects by appendi Default: False -Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details. +Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../integrations/prometheus-metrics.md) documentation for more details. --- diff --git a/docs/configuration/napalm.md b/docs/configuration/napalm.md index 925ec17e6..253bea297 100644 --- a/docs/configuration/napalm.md +++ b/docs/configuration/napalm.md @@ -6,7 +6,7 @@ !!! tip "Dynamic Configuration Parameter" -NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional. +NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../integrations/napalm.md), if installed. Both parameters are optional. !!! note If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed. diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 757416626..bfe412edc 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -1,4 +1,77 @@ -{!models/extras/customfield.md!} +# Custom Fields + +Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. + +However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data. + +Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects. + +## Creating Custom Fields + +Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: + +* Text: Free-form text (intended for single-line use) +* Long text: Free-form of any length; supports Markdown rendering +* Integer: A whole number (positive or negative) +* Boolean: True or false +* Date: A date in ISO 8601 format (YYYY-MM-DD) +* URL: This will be presented as a link in the web UI +* JSON: Arbitrary data stored in JSON format +* Selection: A selection of one of several pre-defined custom choices +* Multiple selection: A selection field which supports the assignment of multiple values +* Object: A single NetBox object of the type defined by `object_type` +* Multiple object: One or more NetBox objects of the type defined by `object_type` + +Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. + +Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. + +A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. + +### Filtering + +The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. + +### Grouping + +!!! note + This feature was introduced in NetBox v3.3. + +Related custom fields can be grouped together within the UI by assigning each the same group name. When at least one custom field for an object type has a group defined, it will appear under the group heading within the custom fields panel under the object view. All custom fields with the same group name will appear under that heading. (Note that the group names must match exactly, or each will appear as a separate heading.) + +This parameter has no effect on the API representation of custom field data. + +### Visibility + +!!! note + This feature was introduced in NetBox v3.3. + +When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI. + +* **Read/write** (default): The custom field is included when viewing and editing objects. +* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.) +* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users. + +Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API. + +### Validation + +NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type: + +* Text: Regular expression (optional) +* Integer: Minimum and/or maximum value (optional) +* Selection: Must exactly match one of the prescribed choices + +### Custom Selection Fields + +Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. + +If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. + +### Custom Object Fields + +An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. + ## Custom Fields in Templates diff --git a/docs/models/extras/customlink.md b/docs/customization/custom-links.md similarity index 100% rename from docs/models/extras/customlink.md rename to docs/customization/custom-links.md diff --git a/docs/customization/export-templates.md b/docs/customization/export-templates.md index 3c7ff7d20..640a97531 100644 --- a/docs/customization/export-templates.md +++ b/docs/customization/export-templates.md @@ -1,4 +1,41 @@ -{!models/extras/exporttemplate.md!} +# Export Templates + +NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates. + +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. + +Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). + +!!! note + The name `table` is reserved for internal use. + +!!! warning + Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users. + +The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: + +```jinja2 +{% for rack in queryset %} +Rack: {{ rack.name }} +Site: {{ rack.site.name }} +Height: {{ rack.u_height }}U +{% endfor %} +``` + +To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. + +If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: +``` +{% for server in queryset %} +{% set data = server.get_config_context() %} +{{ data.syslog }} +{% endfor %} +``` + +The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) + +A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. + ## REST API Integration diff --git a/docs/development/models.md b/docs/development/models.md index b6b2e4da2..be3608a30 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -8,12 +8,12 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ ### Features Matrix -* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log -* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects +* [Change logging](../features/change-logging.md) - Changes to these objects are automatically recorded in the change log +* [Webhooks](../features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects * [Custom fields](../customization/custom-fields.md) - These models support the addition of user-defined fields * [Export templates](../customization/export-templates.md) - Users can create custom export templates for these models -* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags -* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary +* [Tagging](../features/tags.md) - The models can be tagged with user-defined tags +* [Journaling](../features/journaling.md) - These models support persistent historical commentary * Nesting - These models can be nested recursively to create a hierarchy | Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | diff --git a/docs/additional-features/change-logging.md b/docs/features/change-logging.md similarity index 100% rename from docs/additional-features/change-logging.md rename to docs/features/change-logging.md diff --git a/docs/features/circuits.md b/docs/features/circuits.md new file mode 100644 index 000000000..63c8bc970 --- /dev/null +++ b/docs/features/circuits.md @@ -0,0 +1,3 @@ +# Circuits + +TODO diff --git a/docs/features/contacts.md b/docs/features/contacts.md new file mode 100644 index 000000000..fb23e53da --- /dev/null +++ b/docs/features/contacts.md @@ -0,0 +1,3 @@ +# Contacts + +TODO diff --git a/docs/models/extras/configcontext.md b/docs/features/context-data.md similarity index 100% rename from docs/models/extras/configcontext.md rename to docs/features/context-data.md diff --git a/docs/features/customization.md b/docs/features/customization.md new file mode 100644 index 000000000..ae775312e --- /dev/null +++ b/docs/features/customization.md @@ -0,0 +1,3 @@ +# Customization + +TODO diff --git a/docs/features/devices-cabling.md b/docs/features/devices-cabling.md new file mode 100644 index 000000000..ab09b4443 --- /dev/null +++ b/docs/features/devices-cabling.md @@ -0,0 +1,3 @@ +# Devices & Cabling + +TODO diff --git a/docs/features/facilities.md b/docs/features/facilities.md new file mode 100644 index 000000000..5ace5b18b --- /dev/null +++ b/docs/features/facilities.md @@ -0,0 +1,3 @@ +# Facilities + +TODO \ No newline at end of file diff --git a/docs/features/ipam.md b/docs/features/ipam.md new file mode 100644 index 000000000..f263f3ab3 --- /dev/null +++ b/docs/features/ipam.md @@ -0,0 +1,3 @@ +# IP Address Management + +TODO diff --git a/docs/additional-features/journaling.md b/docs/features/journaling.md similarity index 100% rename from docs/additional-features/journaling.md rename to docs/features/journaling.md diff --git a/docs/features/l2vpn-overlay.md b/docs/features/l2vpn-overlay.md new file mode 100644 index 000000000..002c483f9 --- /dev/null +++ b/docs/features/l2vpn-overlay.md @@ -0,0 +1,3 @@ +# L2VPN & Overlay + +TODO diff --git a/docs/features/permissions.md b/docs/features/permissions.md new file mode 100644 index 000000000..a422ca7b3 --- /dev/null +++ b/docs/features/permissions.md @@ -0,0 +1,3 @@ +# Object-Based Permissions + +TODO diff --git a/docs/features/power-tracking.md b/docs/features/power-tracking.md new file mode 100644 index 000000000..1267af1e6 --- /dev/null +++ b/docs/features/power-tracking.md @@ -0,0 +1,3 @@ +# Power Tracking + +TODO diff --git a/docs/features/services.md b/docs/features/services.md new file mode 100644 index 000000000..338fc349a --- /dev/null +++ b/docs/features/services.md @@ -0,0 +1,3 @@ +# Services + +TODO diff --git a/docs/features/sso.md b/docs/features/sso.md new file mode 100644 index 000000000..b4f9782c2 --- /dev/null +++ b/docs/features/sso.md @@ -0,0 +1,3 @@ +# Single Sign-On (SSO) + +TODO diff --git a/docs/models/extras/tag.md b/docs/features/tags.md similarity index 100% rename from docs/models/extras/tag.md rename to docs/features/tags.md diff --git a/docs/features/tenancy.md b/docs/features/tenancy.md new file mode 100644 index 000000000..20534b13c --- /dev/null +++ b/docs/features/tenancy.md @@ -0,0 +1,3 @@ +# Tenancy + +TODO diff --git a/docs/features/vlan-management.md b/docs/features/vlan-management.md new file mode 100644 index 000000000..3d4fd4d1f --- /dev/null +++ b/docs/features/vlan-management.md @@ -0,0 +1,3 @@ +# VLAN Management + +TODO diff --git a/docs/models/extras/webhook.md b/docs/features/webhooks.md similarity index 67% rename from docs/models/extras/webhook.md rename to docs/features/webhooks.md index 9f64401ae..4705243d1 100644 --- a/docs/models/extras/webhook.md +++ b/docs/features/webhooks.md @@ -81,3 +81,59 @@ If no body template is specified, the request body will be populated with a JSON } } ``` + +## Conditional Webhooks + +A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active": + +```json +{ + "and": [ + { + "attr": "status.value", + "value": "active" + } + ] +} +``` + +For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md). + +## Webhook Processing + +When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks. + +A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI. + +## Troubleshooting + +To assist with verifying that the content of outgoing webhooks is rendered correctly, NetBox provides a simple HTTP listener that can be run locally to receive and display webhook requests. First, modify the target URL of the desired webhook to `http://localhost:9000/`. This will instruct NetBox to send the request to the local server on TCP port 9000. Then, start the webhook receiver service from the NetBox root directory: + +```no-highlight +$ python netbox/manage.py webhook_receiver +Listening on port http://localhost:9000. Stop with CONTROL-C. +``` + +You can test the receiver itself by sending any HTTP request to it. For example: + +```no-highlight +$ curl -X POST http://localhost:9000 --data '{"foo": "bar"}' +``` + +The server will print output similar to the following: + +```no-highlight +[1] Tue, 07 Apr 2020 17:44:02 GMT 127.0.0.1 "POST / HTTP/1.1" 200 - +Host: localhost:9000 +User-Agent: curl/7.58.0 +Accept: */* +Content-Length: 14 +Content-Type: application/x-www-form-urlencoded + +{"foo": "bar"} +------------ +``` + +Note that `webhook_receiver` does not actually _do_ anything with the information received: It merely prints the request headers and body for inspection. + +Now, when the NetBox webhook is triggered and processed, you should see its headers and content appear in the terminal where the webhook receiver is listening. If you don't, check that the `rqworker` process is running and that webhook events are being placed into the queue (visible under the NetBox admin UI). diff --git a/docs/features/wireless.md b/docs/features/wireless.md new file mode 100644 index 000000000..ff3d5d10b --- /dev/null +++ b/docs/features/wireless.md @@ -0,0 +1,3 @@ +# Wireless + +TODO diff --git a/docs/getting-started/populating-data.md b/docs/getting-started/populating-data.md index e182a9d52..bb0e8e17f 100644 --- a/docs/getting-started/populating-data.md +++ b/docs/getting-started/populating-data.md @@ -39,4 +39,4 @@ Sometimes you'll find that data you need to populate in NetBox can be easily red You can also use the REST API to facilitate the population of data in NetBox. The REST API offers full programmatic control over the creation of objects, subject to the same validation rules enforced by the UI forms. Additionally, the REST API supports the bulk creation of multiple objects using a single request. -For more information about this option, see the [REST API documentation](../rest-api/overview.md). +For more information about this option, see the [REST API documentation](../integrations/rest-api.md). diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 7c4a60500..eeb5e6f20 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -201,7 +201,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will ### NAPALM -Integration with the [NAPALM automation](../additional-features/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device. +Integration with the [NAPALM automation](../integrations/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device. ```no-highlight sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt" diff --git a/docs/graphql-api/overview.md b/docs/integrations/graphql-api.md similarity index 100% rename from docs/graphql-api/overview.md rename to docs/integrations/graphql-api.md diff --git a/docs/additional-features/napalm.md b/docs/integrations/napalm.md similarity index 100% rename from docs/additional-features/napalm.md rename to docs/integrations/napalm.md diff --git a/docs/additional-features/prometheus-metrics.md b/docs/integrations/prometheus-metrics.md similarity index 100% rename from docs/additional-features/prometheus-metrics.md rename to docs/integrations/prometheus-metrics.md diff --git a/docs/rest-api/overview.md b/docs/integrations/rest-api.md similarity index 78% rename from docs/rest-api/overview.md rename to docs/integrations/rest-api.md index 5fc4f18bb..3a5aed055 100644 --- a/docs/rest-api/overview.md +++ b/docs/integrations/rest-api.md @@ -91,7 +91,7 @@ Lists of objects can be filtered using a set of query parameters. For example, t GET /api/dcim/interfaces/?device_id=123 ``` -See the [filtering documentation](filtering.md) for more details. +See the [filtering documentation](../reference/filtering.md) for more details. ## Serialization @@ -269,7 +269,7 @@ The brief format is supported for both lists and individual objects. ### Excluding Config Contexts -When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. +When retrieving devices and virtual machines via the REST API, each will include its rendered [configuration context data](../features/context-data.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. ## Pagination @@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.' ### Creating a New Object -To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. +To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication section](#authenticating-to-the-api) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. ```no-highlight curl -s -X POST \ @@ -561,3 +561,96 @@ http://netbox/api/dcim/sites/ \ !!! note The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted. + +## Authentication + +The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. + +### Tokens + +A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. + +!!! note + All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts. + +Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. + +By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. + +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. + +#### Client IP Restriction + +!!! note + This feature was introduced in NetBox v3.3. + +Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.) + + +### Authenticating to the API + +An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token: + +``` +$ curl -H "Authorization: Token $TOKEN" \ +-H "Accept: application/json; indent=4" \ +https://netbox/api/dcim/sites/ +{ + "count": 10, + "next": null, + "previous": null, + "results": [...] +} +``` + +A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/security.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: + +``` +$ curl https://netbox/api/dcim/sites/ +{ + "detail": "Authentication credentials were not provided." +} +``` + +When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently. + +!!! note + The "last used" time for tokens will not be updated while maintenance mode is enabled. + +### Initial Token Provisioning + +Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. + +To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint: + +``` +$ curl -X POST \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +https://netbox/api/users/tokens/provision/ \ +--data '{ + "username": "hankhill", + "password": "I<3C3H8", +}' +``` + +Note that we are _not_ passing an existing REST API token with this request. If the supplied credentials are valid, a new REST API token will be automatically created for the user. Note that the key will be automatically generated, and write ability will be enabled. + +```json +{ + "id": 6, + "url": "https://netbox/api/users/tokens/6/", + "display": "3c9cb9 (hankhill)", + "user": { + "id": 2, + "url": "https://netbox/api/users/users/2/", + "display": "hankhill", + "username": "hankhill" + }, + "created": "2021-06-11T20:09:13.339367Z", + "expires": null, + "key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9", + "write_enabled": true, + "description": "" +} +``` diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md deleted file mode 100644 index 3f6860758..000000000 --- a/docs/models/extras/customfield.md +++ /dev/null @@ -1,73 +0,0 @@ -# Custom Fields - -Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. - -However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data. - -Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects. - -## Creating Custom Fields - -Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: - -* Text: Free-form text (intended for single-line use) -* Long text: Free-form of any length; supports Markdown rendering -* Integer: A whole number (positive or negative) -* Boolean: True or false -* Date: A date in ISO 8601 format (YYYY-MM-DD) -* URL: This will be presented as a link in the web UI -* JSON: Arbitrary data stored in JSON format -* Selection: A selection of one of several pre-defined custom choices -* Multiple selection: A selection field which supports the assignment of multiple values -* Object: A single NetBox object of the type defined by `object_type` -* Multiple object: One or more NetBox objects of the type defined by `object_type` - -Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. - -Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. - -A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. - -### Filtering - -The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. - -### Grouping - -!!! note - This feature was introduced in NetBox v3.3. - -Related custom fields can be grouped together within the UI by assigning each the same group name. When at least one custom field for an object type has a group defined, it will appear under the group heading within the custom fields panel under the object view. All custom fields with the same group name will appear under that heading. (Note that the group names must match exactly, or each will appear as a separate heading.) - -This parameter has no effect on the API representation of custom field data. - -### Visibility - -!!! note - This feature was introduced in NetBox v3.3. - -When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI. - -* **Read/write** (default): The custom field is included when viewing and editing objects. -* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.) -* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users. - -Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API. - -### Validation - -NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type: - -* Text: Regular expression (optional) -* Integer: Minimum and/or maximum value (optional) -* Selection: Must exactly match one of the prescribed choices - -### Custom Selection Fields - -Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. - -If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. - -### Custom Object Fields - -An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md deleted file mode 100644 index e76a3ad47..000000000 --- a/docs/models/extras/exporttemplate.md +++ /dev/null @@ -1,37 +0,0 @@ -# Export Templates - -NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates. - -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. - -Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). - -!!! note - The name `table` is reserved for internal use. - -!!! warning - Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users. - -The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: - -```jinja2 -{% for rack in queryset %} -Rack: {{ rack.name }} -Site: {{ rack.site.name }} -Height: {{ rack.u_height }}U -{% endfor %} -``` - -To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. - -If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: -``` -{% for server in queryset %} -{% set data = server.get_config_context() %} -{{ data.syslog }} -{% endfor %} -``` - -The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) - -A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. diff --git a/docs/models/extras/imageattachment.md b/docs/models/extras/imageattachment.md deleted file mode 100644 index da15462ab..000000000 --- a/docs/models/extras/imageattachment.md +++ /dev/null @@ -1,3 +0,0 @@ -# Image Attachments - -Certain objects in NetBox support the attachment of uploaded images. These will be saved to the NetBox server and made available whenever the object is viewed. diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md deleted file mode 100644 index 82dbc955a..000000000 --- a/docs/models/users/objectpermission.md +++ /dev/null @@ -1,69 +0,0 @@ -# Object Permissions - -A permission in NetBox represents a relationship shared by several components: - -* Object type(s) - One or more types of object in NetBox -* User(s)/Group(s) - One or more users or groups of users -* Action(s) - The action(s) that can be performed on an object -* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects - -At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). - -## Actions - -There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): - -* **View** - Retrieve an object from the database -* **Add** - Create a new object -* **Change** - Modify an existing object -* **Delete** - Delete an existing object - -In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. - -!!! note - Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`. - -## Constraints - -Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. - -All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints. - -```json -{ - "status": "active", - "region__name": "Americas" -} -``` - -The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. - -To achieve a logical OR with a different set of constraints, define multiple objects within a list. For example, if you want to constrain the permission to VLANs with an ID between 100 and 199 _or_ a status of "reserved," do the following: - -```json -[ - { - "vid__gte": 100, - "vid__lt": 200 - }, - { - "status": "reserved" - } -] -``` - -Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. - -### User Token - -!!! info "This feature was introduced in NetBox v3.3" - -When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as: - -```json -{ - "created_by": "$user" -} -``` - -The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. diff --git a/docs/models/users/token.md b/docs/models/users/token.md deleted file mode 100644 index f6c5bfe80..000000000 --- a/docs/models/users/token.md +++ /dev/null @@ -1,19 +0,0 @@ -## Tokens - -A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. - -!!! note - All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts. - -Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. - -By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. - -Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. - -### Client IP Restriction - -!!! note - This feature was introduced in NetBox v3.3. - -Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.) diff --git a/docs/rest-api/filtering.md b/docs/reference/filtering.md similarity index 100% rename from docs/rest-api/filtering.md rename to docs/reference/filtering.md diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 93ff33d95..06b889c22 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -357,7 +357,7 @@ And the response: ... ``` -All GraphQL requests are made at the `/graphql` URL (which also serves the GraphiQL UI). The API is currently read-only, however users who wish to disable it until needed can do so by setting the `GRAPHQL_ENABLED` configuration parameter to False. For more detail on NetBox's GraphQL implementation, see [the GraphQL API documentation](../graphql-api/overview.md). +All GraphQL requests are made at the `/graphql` URL (which also serves the GraphiQL UI). The API is currently read-only, however users who wish to disable it until needed can do so by setting the `GRAPHQL_ENABLED` configuration parameter to False. For more detail on NetBox's GraphQL implementation, see [the GraphQL API documentation](../integrations/graphql-api.md). #### IP Ranges ([#834](https://github.com/netbox-community/netbox/issues/834)) diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md deleted file mode 100644 index 411063338..000000000 --- a/docs/rest-api/authentication.md +++ /dev/null @@ -1,73 +0,0 @@ -# REST API Authentication - -The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. - -{!models/users/token.md!} - -## Authenticating to the API - -An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token: - -``` -$ curl -H "Authorization: Token $TOKEN" \ --H "Accept: application/json; indent=4" \ -https://netbox/api/dcim/sites/ -{ - "count": 10, - "next": null, - "previous": null, - "results": [...] -} -``` - -A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/security.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: - -``` -$ curl https://netbox/api/dcim/sites/ -{ - "detail": "Authentication credentials were not provided." -} -``` - -When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently. - -!!! note - The "last used" time for tokens will not be updated while maintenance mode is enabled. - -## Initial Token Provisioning - -Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. - -To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint: - -``` -$ curl -X POST \ --H "Content-Type: application/json" \ --H "Accept: application/json; indent=4" \ -https://netbox/api/users/tokens/provision/ \ ---data '{ - "username": "hankhill", - "password": "I<3C3H8", -}' -``` - -Note that we are _not_ passing an existing REST API token with this request. If the supplied credentials are valid, a new REST API token will be automatically created for the user. Note that the key will be automatically generated, and write ability will be enabled. - -```json -{ - "id": 6, - "url": "https://netbox/api/users/tokens/6/", - "display": "3c9cb9 (hankhill)", - "user": { - "id": 2, - "url": "https://netbox/api/users/users/2/", - "display": "hankhill", - "username": "hankhill" - }, - "created": "2021-06-11T20:09:13.339367Z", - "expires": null, - "key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9", - "write_enabled": true, - "description": "" -} -``` diff --git a/mkdocs.yml b/mkdocs.yml index 4c5279127..f76a91484 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,26 @@ markdown_extensions: alternate_style: true nav: - Introduction: 'introduction.md' + - Features: + - Facilities: 'features/facilities.md' + - Tenancy: 'features/tenancy.md' + - Contacts: 'features/contacts.md' + - Devices & Cabling: 'features/devices-cabling.md' + - Power Tracking: 'features/power-tracking.md' + - IPAM: 'features/ipam.md' + - VLAN Management: 'features/vlan-management.md' + - Service Mapping: 'features/services.md' + - L2VPN & Overlay: 'features/l2vpn-overlay.md' + - Circuits: 'features/circuits.md' + - Wireless: 'features/wireless.md' + - Context Data: 'features/context-data.md' + - Change Logging: 'features/change-logging.md' + - Journaling: 'features/journaling.md' + - Webhooks: 'features/webhooks.md' + - Tags: 'features/tags.md' + - Customization: 'features/customization.md' + - Object-Based Permissions: 'features/permissions.md' + - Single Sign-On (SSO): 'features/sso.md' - Installation & Upgrade: - Installing NetBox: 'installation/index.md' - 1. PostgreSQL: 'installation/1-postgresql.md' @@ -90,19 +110,16 @@ nav: - Development: 'configuration/development.md' - Customization: - Custom Fields: 'customization/custom-fields.md' + - Custom Links: 'customization/custom-links.md' - Custom Validation: 'customization/custom-validation.md' - - Custom Links: 'models/extras/customlink.md' - Export Templates: 'customization/export-templates.md' - - Custom Scripts: 'customization/custom-scripts.md' - Reports: 'customization/reports.md' - - Additional Features: - - Change Logging: 'additional-features/change-logging.md' - - Context Data: 'models/extras/configcontext.md' - - Journaling: 'additional-features/journaling.md' - - NAPALM: 'additional-features/napalm.md' - - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - - Tags: 'models/extras/tag.md' - - Webhooks: 'additional-features/webhooks.md' + - Custom Scripts: 'customization/custom-scripts.md' + - Integrations: + - REST API: 'integrations/rest-api.md' + - GraphQL API: 'integrations/graphql-api.md' + - NAPALM: 'integrations/napalm.md' + - Prometheus Metrics: 'integrations/prometheus-metrics.md' - Plugins: - Using Plugins: 'plugins/index.md' - Developing Plugins: @@ -128,12 +145,6 @@ nav: - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' - - REST API: - - Overview: 'rest-api/overview.md' - - Filtering & Ordering: 'rest-api/filtering.md' - - Authentication: 'rest-api/authentication.md' - - GraphQL API: - - Overview: 'graphql-api/overview.md' - Data Model: - Circuits: - Circuit: 'models/circuits/circuit.md' @@ -143,8 +154,6 @@ nav: - Provider Network: 'models/circuits/providernetwork.md' - DCIM: - Cable: 'models/dcim/cable.md' - - CablePath: 'models/dcim/cablepath.md' - - CableTermination: 'models/dcim/cabletermination.md' - ConsolePort: 'models/dcim/consoleport.md' - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md' - ConsoleServerPort: 'models/dcim/consoleserverport.md' @@ -203,7 +212,6 @@ nav: - VRF: 'models/ipam/vrf.md' - Tenancy: - Contact: 'models/tenancy/contact.md' - - ContactAssignment: 'models/tenancy/contactassignment.md' - ContactGroup: 'models/tenancy/contactgroup.md' - ContactRole: 'models/tenancy/contactrole.md' - Tenant: 'models/tenancy/tenant.md' @@ -219,6 +227,7 @@ nav: - WirelessLANGroup: 'models/wireless/wirelesslangroup.md' - WirelessLink: 'models/wireless/wirelesslink.md' - Reference: + - Filtering: 'reference/filtering.md' - Conditions: 'reference/conditions.md' - Markdown: 'reference/markdown.md' - Development: From 4c899f151cce9fae6ae1596c481f2fa92e6a6cf9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Aug 2022 14:12:51 -0400 Subject: [PATCH 206/245] Drop markdown-include --- mkdocs.yml | 3 --- requirements.txt | 1 - 2 files changed, 4 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index f76a91484..78ca5031c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,9 +47,6 @@ extra_css: markdown_extensions: - admonition - attr_list - - markdown_include.include: - base_path: 'docs/' - headingOffset: 1 - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg diff --git a/requirements.txt b/requirements.txt index 8a7dd79d4..facf925f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,6 @@ graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.4.1 -markdown-include==0.7.0 mkdocs-material==8.3.9 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 From 8c0ef1a0a2e324a8b46af2591a9f1b087fcecb4d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 4 Aug 2022 16:35:32 -0400 Subject: [PATCH 207/245] Started on feature docs --- docs/features/contacts.md | 23 +++++++++- docs/features/devices-cabling.md | 78 +++++++++++++++++++++++++++++++- docs/features/facilities.md | 57 ++++++++++++++++++++++- docs/features/tenancy.md | 18 +++++++- 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/docs/features/contacts.md b/docs/features/contacts.md index fb23e53da..7d717b2de 100644 --- a/docs/features/contacts.md +++ b/docs/features/contacts.md @@ -1,3 +1,24 @@ # Contacts -TODO +Much like [tenancy](./tenancy.md), contact assignment enables you to track ownership of resources modeled in NetBox. A contact represents an individual responsible for a resource within the context of its assigned role. + +```mermaid +flowchart TD + ContactGroup --> ContactGroup & Contact + ContactRole & Contact --> assignment([Assignment]) + assignment --> Object +``` + +## Contact Groups + +Contacts can be grouped arbitrarily into a recursive hierarchy, and a contact can be assigned to a group at any level within the hierarchy. + +## Contact Roles + +A contact role defines the relationship of a contact to an assigned object. For example, you might define roles for administrative, operational, and emergency contacts. + +## Contacts + +A contact should represent an individual or permanent point of contact. Each contact must define a name, and may optionally include a title, phone number, email address, and related details. + +Contacts are reused for assignments, so each unique contact must be created only once and can be assigned to any number of NetBox objects, and there is no limit to the number of assigned contacts an object may have. Most core objects in NetBox can have contacts assigned to them. diff --git a/docs/features/devices-cabling.md b/docs/features/devices-cabling.md index ab09b4443..7297f79ac 100644 --- a/docs/features/devices-cabling.md +++ b/docs/features/devices-cabling.md @@ -1,3 +1,79 @@ # Devices & Cabling -TODO +At its heart, NetBox is a tool for modeling your network infrastructure, and the device object is pivotal to that function. A device can be any piece of physical hardware installed within your network, such as server, router, or switch, and may optionally be mounted within a rack. Within each device, resources such as network interfaces and console ports are modeled as discrete components, which may optionally be grouped into modules. + +NetBox uses device types to represent unique real-world device models. This allows a user to define a device type and all its components once, and easily replicate an unlimited number of device instances from it. + +```mermaid +flowchart TD + Manufacturer -.-> Platform & DeviceType & ModuleType + Manufacturer --> DeviceType & ModuleType + DeviceRole & Platform & DeviceType --> Device + Device & ModuleType ---> Module + Device & Module --> Interface & ConsolePort & PowerPort & ... +``` + +## Manufacturers + +A manufacturer generally represents an organization which produces hardware devices. These can be defined by users, however they should represent an actual entity rather than some abstract idea. + +## Device Types + +A device type represents a unique combination of manufacturer and hardware model which maps to discrete make and model of device which exists in the real world. Each device type typically has a number of components created on it, representing network interfaces, device bays, and so on. New devices of this type can then be created in NetBox, and any associated components will be automatically replicated from the device type. This avoids needing to tediously recreate components for each device as it is added in NetBox. + +!!! tip "The Device Type Library" + While users are always free to create their own device types in NetBox, many find it convenient to draw from our [community library](https://github.com/netbox-community/devicetype-library) of pre-defined device types. This is possible because a particular make and model of device is applicable universally and never changes. + +All the following can be modeled as components: + +* Interfaces +* Console ports +* Console server ports +* Power ports +* Power outlets +* Pass-through ports (front and rear) +* Module bays (which house modules) +* Device bays (which house child devices) + +For example, a Juniper EX4300-48T device type might have the following component templates defined: + +* One template for a console port ("Console") +* Two templates for power ports ("PSU0" and "PSU1") +* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") +* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") + +Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. + +!!! note "Component Instantiation is not Retroactive" + The instantiation of components from a device type definition occurs only at the time of device creation. If you modify the components assigned to a device type, it will not affect devices which have already been created. This guards against any inadvertent changes to existing devices. However, you always have the option of adding, modifying, or deleting components on existing devices. (These changes can easily be applied to multiple devices at once using the bulk operations available in the UI.) + +## Devices + +Whereas a device type defines the make and model of a device, a device itself represents an actual piece of installed hardware somewhere in the real world. A device can be installed at a particular position within an equipment rack, or simply associated with a site (and optionally with a location within that site). + +Each device can have an operational status, functional role, and software platform assigned. Device components are instantiated automatically from the assigned device type upon creation. + +### Virtual Chassis + +Sometimes it is necessary to model a set of physical devices as sharing a single management plane. Perhaps the most common example of such a scenario is stackable switches. These can be modeled as virtual chassis in NetBox, with one device acting as the chassis master and the rest as members. All components of member devices will appear on the master. + +## Module Types & Modules + +Much like device types and devices, module types can instantiate discrete modules, which are hardware components installed within devices. Modules often have their own child components, which become available to the parent device. For example, when modeling a chassis-based switch with multiple line cards in NetBox, the chassis would be created (from a device type) as a device, and each of its line cards would be instantiated from a module type as a module installed in one of the device's module bays. + +!!! tip "Device Bays vs. Module Bays" + What's the difference between device bays and module bays? Device bays are appropriate when the installed hardware has its own management plane, isolated from the parent device. A common example is a blade server chassis in which the blades share power but operate independently. In contrast, a module bay holds a module which does _not_ operate independently of its parent device, as with the chassis switch line card example mentioned above. + +One especially nice feature of modules is that templated components can be automatically renamed according to the module bay into which the parent module is installed. For example, if we create a module type with interfaces named `Gi{module}/0/1-48` and install a module of this type into module bay 7 of a device, NetBox will create interfaces named `Gi7/0/1-48`. + +## Cables + +NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.) + +Either end of a cable may terminate to multiple objects of the same type. For example, a network interface can be connected via a fiber optic cable to two discrete ports on a patch panel (each port attaching to an individual fiber strand in the patch cable). + +```mermaid +flowchart LR + Interface --> Cable + Cable --> fp1[Front Port] & fp2[Front Port] +``` diff --git a/docs/features/facilities.md b/docs/features/facilities.md index 5ace5b18b..0fb65aae7 100644 --- a/docs/features/facilities.md +++ b/docs/features/facilities.md @@ -1,3 +1,58 @@ # Facilities -TODO \ No newline at end of file +From global regions down to individual equipment racks, NetBox allows you to model your network's entire presence. This is accomplished through the use of several purpose-built models. The graph below illustrates these models and their relationships. + +```mermaid +flowchart TD + Region --> Region + SiteGroup --> SiteGroup + Region & SiteGroup --> Site + Site --> Location & Device + Location --> Location + Location --> Rack & Device + Rack --> Device + Site --> Rack + RackRole --> Rack +``` + +## Regions + +Regions represent geographic domains in which your network or its customers have a presence. These are typically used to model countries, states, and cities, although NetBox does not prescribe any precise uses and your needs may differ. + +Regions are self-nesting, so you can define child regions within a parent, and grandchildren within each child. For example, you might create a hierarchy like this: + +* Europe + * France + * Germany + * Spain +* North America + * Canada + * United States + * California + * New York + * Texas + +Regions will always be listed alphabetically by name within each parent, and there is no maximum depth for the hierarchy. + +## Site Groups + +Like regions, site groups can be arranged in a recursive hierarchy for grouping sites. However, whereas regions are intended for geographic organization, site groups may be used for functional grouping. For example, you might classify sites as corporate, branch, or customer sites in addition to where they are physically located. + +The use of both regions and site groups affords to independent but complementary dimensions across which sites can be organized. + +## Site + +A site typically represents a building within a region and/or site group. Each site is assigned an operational status (e.g. active or planned), and can have a discrete mailing address and GPS coordinates assigned to it. + +## Location + +A location can be any logical subdivision within a building, such as a floor or room. Like regions and site groups, locations can be nested into a self-recursive hierarchy for maximum flexibility. And like sites, each location has an operational status assigned to it. + +## Rack + +Finally, NetBox models each equipment rack as a discrete object within a site and location. These are physical objects into which devices are installed. Each rack can be assigned an operational status, type, facility ID, and other attributes related to inventory tracking. Each rack also must define a height (in rack units) and width, and may optionally specify its physical dimensions. + +Each rack must be associated to a site, but the assignment to a location within that site is optional. Users can also create custom roles to which racks can be assigned. + +!!! tip "Devices" + You'll notice in the diagram above that a device can be installed within a site, location, or rack. This approach affords plenty of flexibility as not all sites need to define child locations, and not all devices reside in racks. diff --git a/docs/features/tenancy.md b/docs/features/tenancy.md index 20534b13c..fe6d8e5a8 100644 --- a/docs/features/tenancy.md +++ b/docs/features/tenancy.md @@ -1,3 +1,19 @@ # Tenancy -TODO +Most core objects within NetBox's data model support _tenancy_. This is the association of an object with a particular tenant to convey ownership or dependency. For example, an enterprise might represent its internal business units as tenants, whereas a managed services provider might create a tenant in NetBox to represent each of its customers. + +```mermaid +flowchart TD + TenantGroup --> TenantGroup & Tenant + Tenant --> Site & Device & Prefix & Circuit & ... +``` + +## Tenant Groups + +Tenants can be grouped by any logic that your use case demands, and groups can nested recursively for maximum flexibility. For example, You might define a parent "Customers" group with child groups "Current" and "Past" within it. A tenant can be assigned to a group at any level within the hierarchy. + +## Tenants + +Typically, the tenant model is used to represent a customer or internal organization, however it can be used for whatever purpose meets your needs. + +Most core objects within NetBox can be assigned to particular tenant, so this model provides a very convenient way to correlate ownership across object types. For example, each of your customers might have its own racks, devices, IP addresses, circuits and so on: These can all be easily tracked via tenant assignment. From 602cf8c5fa371d767af70c6d0d9333c5732e9c7f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Aug 2022 11:29:42 -0400 Subject: [PATCH 208/245] Fixes #9939: Fix list of next nodes for split paths under trace view --- docs/release-notes/version-3.3.md | 17 ++--------------- netbox/dcim/models/cables.py | 4 ++-- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 2a3935e5e..74ed7f10c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -97,22 +97,9 @@ Custom field UI visibility has no impact on API operation. * [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location -### Bug Fixes (from Beta1) +### Bug Fixes (from Beta2) -* [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device -* [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data -* [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form -* [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables -* [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view -* [#9778](https://github.com/netbox-community/netbox/issues/9778) - Fix exception during cable deletion after deleting a connected termination -* [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects -* [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks -* [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination -* [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination -* [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects -* [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647) -* [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination -* [#9847](https://github.com/netbox-community/netbox/issues/9847) - Respect `desc_units` when ordering rack units +* [#9939](https://github.com/netbox-community/netbox/issues/9939) - Fix list of next nodes for split paths under trace view ### Plugins API diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 321d808ff..2be64451f 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -677,6 +677,6 @@ class CablePath(models.Model): """ Return all available next segments in a split cable path. """ - rearport = path_node_to_object(self._nodes[-1]) + rearports = self.path_objects[-1] - return FrontPort.objects.filter(rear_port=rearport) + return FrontPort.objects.filter(rear_port__in=rearports) From 7dc2e02e226445cffc84deae682c8c9cdeed8dc3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Aug 2022 11:39:04 -0400 Subject: [PATCH 209/245] Fixes #9938: Exclude virtual interfaces from terminations list when connecting a cable --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/forms/connections.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 74ed7f10c..c7d1a30ff 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -99,6 +99,7 @@ Custom field UI visibility has no impact on API operation. ### Bug Fixes (from Beta2) +* [#9938](https://github.com/netbox-community/netbox/issues/9938) - Exclude virtual interfaces from terminations list when connecting a cable * [#9939](https://github.com/netbox-community/netbox/issues/9939) - Fix list of next nodes for split paths under trace view ### Plugins API diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 7552c0c87..cc5cf362f 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -84,6 +84,7 @@ def get_cable_form(a_type, b_type): disabled_indicator='_occupied', query_params={ 'device_id': f'$termination_{cable_end}_device', + 'kind': 'physical', # Exclude virtual interfaces } ) From c1d9cace117fb593fd8e332c370e05096017dac5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 9 Aug 2022 14:21:27 -0400 Subject: [PATCH 210/245] Fixes #9900: Pre-populate site & rack fields for cable connection form --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/tables/template_code.py | 50 ++++++++++---------- netbox/templates/dcim/consoleport.html | 6 +-- netbox/templates/dcim/consoleserverport.html | 6 +-- netbox/templates/dcim/frontport.html | 12 ++--- netbox/templates/dcim/interface.html | 8 ++-- netbox/templates/dcim/powerfeed.html | 2 +- netbox/templates/dcim/poweroutlet.html | 2 +- netbox/templates/dcim/powerport.html | 4 +- netbox/templates/dcim/rearport.html | 8 ++-- 10 files changed, 50 insertions(+), 49 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c7d1a30ff..9ec4eb9b5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -99,6 +99,7 @@ Custom field UI visibility has no impact on API operation. ### Bug Fixes (from Beta2) +* [#9900](https://github.com/netbox-community/netbox/issues/9900) - Pre-populate site & rack fields for cable connection form * [#9938](https://github.com/netbox-community/netbox/issues/9938) - Exclude virtual interfaces from terminations list when connecting a cable * [#9939](https://github.com/netbox-community/netbox/issues/9939) - Fix list of next nodes for split paths under trace view diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 082df56df..04ef74192 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -121,9 +121,9 @@ CONSOLEPORT_BUTTONS = """ {% else %} @@ -153,9 +153,9 @@ CONSOLESERVERPORT_BUTTONS = """ {% else %} @@ -185,8 +185,8 @@ POWERPORT_BUTTONS = """ {% else %} @@ -212,7 +212,7 @@ POWEROUTLET_BUTTONS = """ {% if not record.mark_connected %} - + {% else %} @@ -262,10 +262,10 @@ INTERFACE_BUTTONS = """ {% else %} @@ -301,12 +301,12 @@ FRONTPORT_BUTTONS = """ {% else %} @@ -338,12 +338,12 @@ REARPORT_BUTTONS = """ {% else %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index f132a4ed8..39ffbf552 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -111,13 +111,13 @@ diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index f4da080e8..642e758a3 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -113,13 +113,13 @@ diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index e5f1df5ae..2ef955fe9 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -109,22 +109,22 @@ diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 11e776872..7503e1be2 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -263,16 +263,16 @@ diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 3972b30f3..584454df8 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -158,7 +158,7 @@ {% if not object.mark_connected and not object.cable %}