From ea552c9dd315b64b22f8b654a0ef98d8c32b2ece Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 8 Apr 2022 16:00:33 -0400 Subject: [PATCH 001/113] 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 4c32f23d100e0d98b863fde62960704bac866e7f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Apr 2022 14:45:28 -0400 Subject: [PATCH 002/113] 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 68617898951d0f42ba95c18eefa64c421a37162e Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 14:44:35 +0200 Subject: [PATCH 003/113] 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 d1700d1affd3a0dba063c628368c39c94981bee2 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:33:29 +0200 Subject: [PATCH 004/113] 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 9243d268698a976d187a675a6b0ffc0529fc86ac Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:55:39 +0200 Subject: [PATCH 005/113] 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 921da1049559c15e0aa7983c8bb474e1e731b872 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Apr 2022 13:09:39 -0400 Subject: [PATCH 006/113] 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 74e2781697362cee3fde4a4faa09496bdb942f5b Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:11:02 -0700 Subject: [PATCH 007/113] add file, skeleton from "select all" --- netbox/project-static/src/buttons/selectMultiple.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 netbox/project-static/src/buttons/selectMultiple.ts diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts new file mode 100644 index 000000000..465edc2f3 --- /dev/null +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -0,0 +1,5 @@ +import { getElement, getElements, findFirstAdjacent } from '../util'; + +export function initSelectMultiple(): void { +} + From 6f85f6d755e1d8b31bdf4718790d1e647292e121 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:13:02 -0700 Subject: [PATCH 008/113] create store to store previously checked element --- netbox/project-static/src/stores/previousPkCheck.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 netbox/project-static/src/stores/previousPkCheck.ts diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts new file mode 100644 index 000000000..7fba2faba --- /dev/null +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -0,0 +1,7 @@ +import { createState } from '../state'; + +export const previousPKCheckState = createState<{ hidden: boolean }>( + { hidden: false }, + { persist: false }, +); + From 5266daf280d31a916d07b12ecfa1862a6d327df5 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:14:15 -0700 Subject: [PATCH 009/113] now exports previousPkCheck.ts --- netbox/project-static/src/stores/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/project-static/src/stores/index.ts b/netbox/project-static/src/stores/index.ts index 42d4aa0b5..5e53410ad 100644 --- a/netbox/project-static/src/stores/index.ts +++ b/netbox/project-static/src/stores/index.ts @@ -1,2 +1,3 @@ export * from './objectDepth'; export * from './rackImages'; +export * from './previousPkCheck'; \ No newline at end of file From 86d2774c9237b5c86de645312b5402804cd8a29f Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:36:17 -0700 Subject: [PATCH 010/113] now exports multiselect function --- netbox/project-static/src/buttons/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 6a9001cd1..e677ff599 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; +import { initSelectMultiple } from './selectMultiple'; export function initButtons(): void { for (const func of [ @@ -10,6 +11,7 @@ export function initButtons(): void { initConnectionToggle, initReslug, initSelectAll, + initSelectMultiple, initMoveButtons, ]) { func(); From f94f488a75220e7355c632d3d4b380d1aba749c7 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 12:37:28 -0700 Subject: [PATCH 011/113] clicking a PkCheckbox updates state --- netbox/project-static/dist/netbox.js | Bin 375393 -> 375642 bytes netbox/project-static/dist/netbox.js.map | Bin 344719 -> 345022 bytes .../src/buttons/selectMultiple.ts | 20 ++++++++++++++++-- .../src/stores/previousPkCheck.ts | 6 +++--- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index acd1abbf28c867420280fb8364a550d7f54c9ec4..3d6bb9d1a610d4977fd9c887d49450d097f78f7d 100644 GIT binary patch delta 27130 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 }; + +function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { + console.log(state) + state.set('element', eventTargetElement); } + +export function initSelectMultiple(): void { + const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); + for (const element of checkboxElements) { + element.addEventListener('click', (event) => { + event.stopPropagation(); + updatePreviousPkCheckState(event.target as HTMLInputElement, previousPkCheckState); + }); + } +} diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts index 7fba2faba..a5d06ceee 100644 --- a/netbox/project-static/src/stores/previousPkCheck.ts +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -1,7 +1,7 @@ import { createState } from '../state'; -export const previousPKCheckState = createState<{ hidden: boolean }>( - { hidden: false }, - { persist: false }, +export const previousPkCheckState = createState<{ element: Nullable }>( + { element: null}, + { persist: false } ); From 3c3fcb38449cf670172d4d13962870c920d4610f Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:23:43 -0700 Subject: [PATCH 012/113] added main multi-select function --- .../src/buttons/selectMultiple.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 08b5165e2..68cd57032 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -9,6 +9,43 @@ function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: state.set('element', eventTargetElement); } +function handlePkCheck(event: _MouseEvent, state: StateManager): void { + const eventTargetElement = event.target as HTMLInputElement; + const previousStateElement = state.get('element'); + updatePreviousPkCheckState(eventTargetElement, state); + //Stop if user is not holding shift key + if(event.shiftKey === false){ + return + } + //If no previous state, store event target element as previous state and return + if (previousStateElement === null) { + return updatePreviousPkCheckState(eventTargetElement, state); + } + const checkboxList = getElements('input[type="checkbox"][name="pk"]'); + let changePkCheckboxState = false; + for(const element of checkboxList){ + //The previously clicked checkbox was above the shift clicked checkbox + if(element === previousStateElement){ + if(changePkCheckboxState === true){ + changePkCheckboxState = false; + return + } + changePkCheckboxState = true; + } + //Change loop's current checkbox state to eventTargetElement checkbox state + if(changePkCheckboxState === true){ + element.checked = eventTargetElement.checked; + } + //The previously clicked checkbox was below the shift clicked checkbox + if(element === eventTargetElement){ + if(changePkCheckboxState === true){ + changePkCheckboxState = false + return + } + changePkCheckboxState = true; + } + } +} export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); From 56cd35dcec82d4a2de118040c27bade604760c6e Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:24:12 -0700 Subject: [PATCH 013/113] silly text highlight workaround... --- netbox/project-static/src/buttons/selectMultiple.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 68cd57032..62e66ed0a 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -4,6 +4,10 @@ import { previousPkCheckState } from '../stores'; type PreviousPkCheckState = { element: Nullable }; +function preventTextHighlight(): void { + return +} + function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { console.log(state) state.set('element', eventTargetElement); From 4bd787652e31c2c085a55bfda7f37da8510b163b Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:24:50 -0700 Subject: [PATCH 014/113] click event calls multiselect function --- netbox/project-static/dist/netbox.js | Bin 375642 -> 376041 bytes netbox/project-static/dist/netbox.js.map | Bin 345022 -> 345446 bytes .../src/buttons/selectMultiple.ts | 9 +++++++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 3d6bb9d1a610d4977fd9c887d49450d097f78f7d..b7095fa78873efc9370ca09754b6e9cf3b8641cb 100644 GIT binary patch delta 22477 zcma)kd3+ni_4sFYrQCOX#3;wa83E_a3IBm-0Ve{mmfYjPsL;lj|*t8 zn-w2^Yt>c8kxTbz&V=IXmjsrUfP|nkXaU#4N=jw|F1bhMXcz zJCIj&(w!(I-ayw^IH9Ls>q|lZ8dk_eB296(cI?>u^z8_7$1YW0nO@5atRt-z1ST!$ z7?;5EfSckf$i0H3XBPQvfDp(1k&k9K*R9IetOb%;Y3IqJw$_XuMd zE|SOXV)>+2*Jv5m2lDY)GM!>;+#7YvPUDsb)H;^ATx97it8u22VIibxa)_Qw zHpp|gh!d}$eFbKyt6pew{PE}buaacLW}iOqQZ$z;;r5vIR1smaB5 z!laG;)A*ajgIFs=>N)lsT#2g4pn(x47B6l{IL;`v{XN z^|QLzZXWw5z-Qv4fu3IqTVmn+z!|^?nlilKy ztIF1nxC#^<_sIMgFfyUi*o4@8)mbPZ{`RUa8F1RjWJ@nCg+0oO+?C~G^)+)u`_*$8 z%lG%GU)d+Y$aqTwn_aA#@J@@k|7!Er7MH@M%`R;K#7GdP_gKLwX=KJqV{2Wkff*OS zx_WS5t!v}BOQ%oA6z_KVWBPShsOzwW{9t%QgSkfX@% z7N5L1DI>#|V0O7!2Z1r@l3_OTG9p*}W-jUy*Ir*SG$<3aDIhcy2s8s;ivrJMWNamP z0f)jUTmjc+9~*G!{V`uA8W7S<+QD*hZL?2TSI3EmuHQnX9pdBHpR|*#HDY8;B{+hE ztqlWSNr$Ymn6QIwA^dEb=I0v00s1NF5MR1Thr*)$=cl#;7;C&>3t|NbDF-_WBN>l_ z?Iu)biO))P;+r?D)5AKti|ZIJtRr5)uQSf}lHHCL@b8EpuBn*a*iCR97A-fPyf~`h z(izt#0j0P&dE=1=E-df8-^h%VrVbS#XC3S~p|w|`P~*rnjYEzmY+RvQSy7#J=z|JB zK|ifgUfq=V_Rp%sD{m^7<$Bo2BucRRd~7YD_L%t0O>Q(MZv4e&S*^v5OmnG|xxg%L z*b+i|OPn=}XZ@mSyW63)#C5uYQt4GhIBH~SOGBN2Gl8FTD1z4#2b=W8FHWt@$&@Vd z{76b(cSvl#d0iXfvaFFAC_%|P*d8*mtAL0Dpb^^gz@~&E13rZ}+6$}U08uilO}z8w z^(##d*5%Om1w5m6Pn_xTFo_g1;9=|z@$kjvqWYGVl+}SBS%a#@-M1`64zc4FAIgh& z-J(S%@%3B&ypVHfHL0{G9RS^66QB5H58}nDTd&w>cQ7Ha@PTAYEZJan6va0elb7N( zGX13mj1~}Yk1Ggw7MQsM#DyT*U7&0$kcTk+h=Xm7>j65Gke>oVFqZTM7>k1~$hR{s z?)dNfP^b9me{0qz6i)I9`cy0f{lEb!J+l_kaGP$~s6#j55t`D%P(awj3PMANIC|T) zrV-eN;c!Y<8rWTEm@4k7McJ7#Bhyw|PP2oxd(z->1i}%}?2g-;t6Cfl%?=%rjv^iM z3LVTwrb}FR$13s3+t;hc9oky)AGg=aK9AeT43d7-bB6~-DPoK;w zjRg@Egq?<_Ub5beAiD-#Ll1eLsXmiYnf^}T{b-EqpA0Yxo% z1h(CwjS2clKnSIWLG=i1tGN8m7aIv9=8R0Z1gF2iX#E9vZ4NdW)#~(N!5_#wf=ri# z9UA=xeQ24hU-6-4dymsCdpjIy+gM$;b?sPzYocTNqLn1xRNNikNy0Y>u$Y zm_ZR!vw;ng<+}~+AS}OexVX)OpgKqIb-{V#;d|G^Ipl+T2lki^+MvMrK_Fm3`qZ!| zIyvbV>gxPT1x#yJ?x7*CG)#>FG`m3?5g4Dqz(84&3Ys;Yk+<4zWa2W`aWZd(c;_W5 z@jhvAHCd6>$V5vkavIol2Jnk2_*MUHjtUH+^O*g<9Y{89qu}_JdgsJTf3*$H0MGtv zrR=D7f)7@DnKvk^)*EHBWa%!0;>0)%tV>+>>w`=3@@CZ45jz_Yh8*IZziyQ0=rA(w z5*S|m=GXP0H#h!f?Ph{*w~@)0UUCLFl>uyaqfy%Wk>9 z3*P&!uZ{Gvg%q2LDfD;$Rhu5O=d7}zfHoA%%yg=Ob;Bs)vyC3A_z6m4a zQ2MUv1la&t(}RXKdwj!8hk;F!)pQ%!4)M7MSDLj6KT9V%ckCI!jRd4T?5u6Q6!` zi?g5XG1E4!fw~n$dW=j*X-JnraRmnzoxlWm#Q{q5Y=kgl$F%P0GBjZnqsm18V`XIl zo@k#&AfJ{T_CRr@dGY+mYS5r~`(vAAOECcMP-z~4S59yv)9?gdq#xJvWE0zsOmC@c zvVcOEXS)gWTB3?>jq$RYW>P$xAnhJr_F9<;FA9%url7d~@mni>JX>-nb#mMyEwCP5 z^#67jmEy%4e*1rvj~B1`orQ|=;@^JPO(l7eeS&Qc^Wf0UJlhN?)M_;{JtfpKydpE}jyqQIFrFW&sr)M_u^Kq4+h zr@*T){va6VkSuC#V+!`nV3ds$u`%6{7TXni>PizI?g3`tHzbK#MUIrL1cfsiJU zV8WrIyWmnb11y1~fCXQ`A{kR5f z^V!0S553S!K~ec)hDz|q4!*dRqAa}l`0xL#;MFl)$*eL<+ao}FLu1V%HIc!8`7u1N$!=OO3t{}9nv*ALnT*d9Eh1rdd3)y zTxgh*SSttbi9R4Jc)`pBVd(~Uh)H{r0IdP$d-mlGR4;$*%a_+88JkpsaVgkr&V>|( z*HhqNS4LKJ%mA-AUG2PbqHQe*SU)cY|G1m#;g8+?M-!r2dGV7!)zl2|z-B&hL7{An z3A;Q(qpqk6{Q?Vv46j_#O;|LNU~Hvr>MAhgAWR10Pz;Q?JRy(>E(HEhBNrMPV!;Rqb-mx~8q zT}_2K@m7e_2E_MY9XL765r06@7P2}JI2{QNrV7D9IDjNMG4NWCtk^Q*L$9r@iWTNg zajb(F4Er?FF;4vaH64}W#Ol|}WuEmXn9&l}8BU3cjLPh-245$`iKDL%&EXYwGb}#; z`ekx_GoD~NO2dMj9C&2>9NRo+tMjHy1pnu>BFd%S8Nm(dzQO$q)6c-joE zeKH&+k6TI}y+x7PXo5+UunTdDTbeCk=LG~ppi{_bW(4#QK{XX&L!3VDNu~l;h}(>D z?55L)HrDTHG7Ru3ftf zaFiV5^yxq^jpq}UED3opq5ptch zQZc7s>T{enuS`x(3Qq3W({Gj|d4tCiOm7LGiIX)+V=vj@G7%hF@#|gl#Ok+ZQzlN- zy;Ua%EzAj~y)?wmDeOGq3>#-@*hEpJH%0^@;(UT&(fn;Wu)_pmiZ zXIIF|b}W}*(j`i^7t~QJFcz_v{oFFP9Y`_58))OiSKi&_YUT8@vS(SimYJMv>#){)q;{K74}7lm>+h zgJL#T$ikviHsx64fjZFv5gQvL?LP6z4<17v@%+D?0ddy(9}Xa&82Hdy9<#wgRHvID zRwW|X2=Ga*u`<2t92|R#O zm!SCN-;;3u9{ohQjj@Zjf3kAGZ&O%)*rq5kb5=evGjW^Z5=3pRhuE2rjqM<7$lBO$ zQYJErXjn2zSioyzLu74ZK!4KTELNXLp<(gj6DAZFUp;Y}k%uU6AvP3)({)WEqlKoX z8e@Y~P%ax}(+&qgmNhgP8u(+uPlphSijRI4gj+)0|DIo-wQ0TNq)yJ<@`V$0Hj7XH zZ_WCUP3sgGUKsMg334Jm>`6E90>e4o<_=f4)$D9Y3gDIT;QTv(s8u-=^5`OkGb^ETFzl|31k{8aK}7zgZiRv(1; z62q4stt8sSa5mUVUxeIxRf>=PbHyw^3sMN~<3CSf(ZQyVY`*)vmNFYV&X|F?dw)uzO1M@7|FSFP`~v7^&U!EV!P3UOEsW6d)5EF4R1 zb#E%DZJ5(%V{2`bljGvj@2U*FuyH>)8!9*3H6*FK{qNXIY*)E>`T{Y?x_kCAMwc7B)Ip8;a^IbC27j1>2X)YRA z4;RlSp&r;1Zk^!)VnaP7)KYH|iUvC*G_~9CLvxV^#zYZ1d9Fp+1zjd5EqKX1bTWPz zp)(PWISTEE9;eJl>!H{?ADz1f*tt{C2P2u(FzX`EQ$;tSx*s2+(G92-Z&jhQs5Tq! zT7dLamksYVZ}VaLIPP44HlZj!a{|TV{p)tH~5n6%F__9T?ShupHPGv{i zv#c9-6l$=ecyKYAhjKWu7#UC=U%D6t%UmW_b}Xi2AdCYqS%U5|I%aJ1V1{kY>W4ik zM^1ple>{*(k1@Q7G!~C)42S=*1VMHSE?IjbTTB&<6Ojoj+)U^EKOn|G6CHP&ba0p|xm)%)JWNm7_)YSQ%CBcH{@m1f|3e+6YRPAG8sanoan+r6{|r#iZ4N zVGgJCZZ`@1yWQ*rj2Uha0=a4)0zRHNdDNOE`BZ6zv0<9L20 zTDc5xAdEe&1^eW7JXML7qF#Kc5;0}m%(W;)Zo$_r2O`&+8o&3FtZ4iPZDd7#KWHN> z>cyHBXbbAcy(ZnY z1_VXsTJcLO(AIeaCUVYe5`-b_Swb(znw4k^lOyAZLr@$9Q^TD&^lHe)!v1Q*G%W`3 z{*`DPwd1c>qFOKvHLK9QD1={Gg`TS7fVhG%6$t8ih=K=LF4j21l03K?Z8=53RF0$q z-paC?_-O^wpwSd@fcI<`wv3cstXqQu8;DQUZeh$NSx?#JTMou!R|1$3yK;Tt!}qO0 zTTe>a$ueXr!403sPL@ut_9Awz2c970;*%Cvfn>??zg7$5EJ4oLmFP#%E_*tSPH*X4 zld)@qIJ_3EnZqmUrwd=O7Og~1{EM|f6DNLlExMWHNel-I7kivnabU%nzJ)ND7ZkO5Q9$+~yiCyBy)}c14@T{puz%H=*Kr>@6;h488U|j{U4m;~A zfwfN~oVOEz$bs*vMy>KOIo-^RmcZ}@FkBHkJImpk8dOH{%0L@#uR(6v0Tb{YHE1Pk zvMb>dI}G)Luh!vZO{BEimAn2?yAsmww9A*-@-=pcowbra-B_(b)v6JwVx0#4y)t2E zqru6^5d81r1_ol7Lv$-2@jowu0?j~>yywX3RDnN2d>O0zP%3Z zMI2td9<5kvu@|C|Ue*O?g+_36)}s~~^BDfgdIU*wKqGRBXfA9?3t&$22wGs#Bmh(^ zJ-r^yLZA{}(xMkZdtI+XM^Fo9H=wmLoD|->0XBOak8eN?r;OVRl2f=jZZMY4mhPZz zn>@`-Uy0Ox1+MDFA8$afqdxrPM$`v7rJ6x2c98w`H8VY>-fac0?y@TmLogDHC7D4x zNx@47;t>y+iGUTy7_^Q`Tk(Yq@@`05i}DkLOh;A`U$~kB0=C)ZI4(pK16GJ5;5GH= zAK<4g(WCQ#epl#G)e5t<(Gi4GNHXk+gvSG{+bRoB7Qd)R<&|D5IF!DaKhV={O}{s0*|IhnQMXHG#I@TZ&5 z#nnzL3H^dF0@+F);k34KWA0+O!MM)=+Sw~TXFypM$d_pwYCxQ{ZyPFC$>);sX2y!& z=1>J*dy8r|UUM$0lm2`v`alhi%acuL?QC)!gmWMMdlMQ#7O8(X3Lr304{~U=ys3TC zn;iN?$*}8{UgA;R92oj#J6KyDZ?_?fJWWqCFz@%K(Mr$Q&}=fzAG**fP%P*M_l%c1 zATta>^@tOBq4>s$Ho|l|7t}1$`7ZQ1d3jbZYJt8_^`iF|$?yl7nGpiN6F+4`jI=d~ z`Y7mk{xGV66)+iLh?njjMn_c8=!l|SWsss82d`ZV`KU$e@zQlsA|5<`DTY=-@o@~X zm2jGMg2E$4l0?`cx3wt=H=)w*IO-*+-~@;U5UJ56=pm#Lg${6Ast>2E0;IhNY1Rt| z30`_LfsR4uDtV`y;HAq`=utA$X<1~0;+8Dh1X;9N-IN!O6UtSnwrBvf78qi||H`6M zp{pT>;3`-;J%=73i&--X#}*zpO@e2}WA`LzQ6B$v62TR&bo(SaMYV(+@!>EJ$b>?H zq?X73IujlFk5=j0{V0!MA*}2Ufa!(XmnfXyb&Wg(1^_LYT)cGZPf#0~_@@U@6BLgh zKwIP;Xm4f`(>weNV59{n6R}LUFx(h%FuAdk8ECUkTemg|`1F9nKLUR45_C{ztE9B| zQn2SBWEW#}1$gg<%g}|WOM2uobP!bz5{Uu6FZi;FF|wNvg42h#TVQN6-KJ90L_xAGg1Gzn75KtOi8M&}KZ>5B zVf;D2LywiKOa!F;4YYkV4cmv`dJk1dSAKzPv!LhSzCnkmg{@{L1>u}XEEQn;FFBji zptehHrZ#Q~xJi35;7@vTYS5Xfk)qq*6!;7n^)zV#Sr~|W5L~1yR`n?+UNegw9JF&qx015rB(@A z%@k^t{>MyRhQNIkTB!$6o5WbCiA8|w&ApT`7wA~Iml~vj3zI(TZYtoGw0`Of6_s{N zHDT(_`G~(XLw&f+?`DSGrQ@gHy>VFjIzvTNaOez=QwP;SHxo(WYt>Yhv~q$<&PIZC z{#jJ53MHkZ=TH|=z%xz%MK!2_mBtTJ91Xm4|6vN^2OjCOOQ=eUin#GwSc+7A8MSB@ z5OCAg)NTY+k6%M^vwq8swk8Fx{4&|t*$_fq#l@$S9U9^{gEiMo=4@~vM{y@+AIaNiTKTW>u?%|^(CKX{nB8fH512n99*zxD`q5151t z9|bHti>_!+7R45;H7YB7BN zAi1BVo~KcVq<@jBMAaQ`;wCpFV%o3|E=`G!0_$8_b2kpW1SIR0x_?jA63dtS6IHXC z7+79_$f({I1ES^A=172~5l>EX_}HJQFf6C)6|zRD^%bh0#uvOp&BiyqPFdi{2#Z;d z&-j3vEmgfi?IPyrwByu@6(eqK6s9IwJH%!Yz&=kJRR8(Msh0rI>2Feph!Owf&s0Bw z`xYRN5|aCEN{{gM?*f1R@*PS|;T`W&%kU$ArQXK9A5h!zEAPSwBF)lC?@?t))#9FN z#NT~Ft-`w>q!wV$`_z5l`K|hZ+6fLG!AkGGGx@dl% zOmE^Oanj2ts7(mX8_-SR5h>jpT4xll99X zYJBULfEACQ`;uZnj=uVmT19Xz|B7PEAvnZpihHO5e-Lf~EK=wz3j81#@o#Dric`O) zCP9M8o)s9)MZKG6XsHLju?iBh_EHvJeHl*Z@K zYUm}MIhQu0hJcgZG<3Rt&jxK1dzxOqL3f&dQomsnlZJLu-^2*;)X;F6A7Z;q)<<&w zct2}olK8uMv=N1|aX!5kSiEyS-Az^S{*4=__L93BeJ1QTXJrQE@qG*FK@`Kw7tv$H z(k-IZ6bOm9gieDpo3j)?B3cu0k~kLZE*v9;rj(QTx{z}U_n{3jDZPva+vve(EvFxx zla_ljNy$=1w~^7g1)p#NhAkr`y zc=V?9h1!^p-K(#i$T+9=GC_DFCpOl5;$aQr$3hjo);8>9_sTZ0c4Ev2z2e2Xzt9ks z8^XR(xn4UFB(Or$6K3Ht=<&lhN?!a*6}=t#@Zz=f@8C@T$F)GgVLWFYypXM?optK)XBX1tcyt|5HtxLiReCr6ZXJECD(VE>eqK$l-!Vqkl1Zmyf`&1Z z=ajx!bpuwgFf&2M39n`>EQe7IyMOch=BGYRrj0HPb6_lbc?SkJQjw zD(A%Cy+U(vs-!I_FLh|>duXRina<})#xkiuM8ni3oY#iEr5u40Zu+ld8@~URDwV_!=#} zVrBb(ycNKfXqZ;~qL!{K>xb8cmDC4_^b+mzZ!NuE-vw_V3a#w63|GSp{zqGNI2zJ0 zfgJAC(F~Q&;WKpfib^2|HwVOq)_YU+O4<(uij(*@9jyfg@JAipilDQ!ZX2PXg`s`4$=i5x8nA zXf+FdW-F~h&G?+H(6t{Q-Ab>-OSaLo@k?9jH4xbNguG7RrQ7H}F!|wabP5#W@ohxO zSn)^O=`*M{Cw^%My$YYb19Xfay|{y>=_Lcsh8`!m*$u*7kh7sx`bz_SKCvS4o%DC$ zbK>}J`tPc7CoIjw(d$>#IvFiF(-66a6GJZ?+TfB=8^+gi^u?eLw>1OKDZHnd-qZ=M z_1@HQEC+I!0tSc_s=&x{-3RqynepXnMydJ(#rHvI@I_>Pfey73G5kg|&8+7=z%j8% z#1l^iz*6*rT;{_5^e|{Q*puOaB7novCY~On(Xez&3*CV*-A=EOmRmrt&*DAsA&-E& z_S0qf#4pwJaF3OK7_@3d8~u06m&5ySr&meq+UWtMYh-5ED{b_3D1&#|LD{9Sx&wB` zFSU2jEUEyo4e&95FXBn1Y~fT|@A3QLw0yOLejlNrbWRt27}duCMbKz|2<;QG(lE_o zS;fS2K%gAh!5KP@!zL%)xG$PxH#4CeO9In{OrpKD6M5g%WbK5_L;iA}sl9L&ULt36 z7=U?akW+3Lg9c?c!fQfPIwDyhzLErt)0jbvyl&j zgu?!6m>evNtSJ-MF!{o>ETXPLMOF?PxbYZq(Yu%N(5e?aS+6Hq4+W@8d_S~dvtf_|VmKjdWTsok3Jb4j(-T zI)2{`U?{tD_~1Bf<&u6FpdQPC?InA=TxuJjy(9#2+YtRJGD}yVM)PQ{I|tr7Fm?bZ z{M2&%%^tc@m4!AOYoy_bbvm#EM3ZiH(+jAD`CRGKuhAU7%0sVO;F@mg#2}FCV8x8kAsBY?2z@F-PU-nkx_K7J{?a7yVFLFh>G5(4&^8+Ldm{B- zk8hN03NV@UUXtEGff8Aop&3vjyE1e;HIl;*Z=$R4%^7+l_`ok`=urgfW_OM*o7F=2 zu@(>JU{}WR6*-z;(g!S{i0e49Q@!|;9K8cPzq&lV8HhQM2g5&rf0n1WEp4CXCFl!w z0>6}}%i3E3HN|Vl3>P_v1D;pV1r|w%eHx~pFo)9QhlpYflyBQ~lQ-rc(=c7wFa{hl zh&#sUbE*Cu1~pqDeKJPxpj5mM!j!s6pvZs}o1`g3^(iuqAAXmfg~ciQL2z`t&!GQ+ zU}<${(oNt{2G0cDPW3>?S75hqIg37zo(F)ybJ+!VDfsXt@KN76G(xBq4;-L(QoRM3 zM-R}4V8gg`LG5#R z<_l<}-UH1Av7QDLDK_f;;Z!u7N)^WV@Zk&Sm7w2ly@1wG!8|sOfw;VL0lf!-P1}8l zK7v5SpZN-1fq_Kjc-CRsLZ$MG#vD0J+bB5DOCs$@sz{#6;B}YK>nUFz-}w`Iy%f8I zUWQbV!w{6tzLcH^)-jI{V=#Yy{3fOc%ZKxDo$i4ka{bV0x%xdDNZv&p+b@ItiQ)?` zqYr>UlwVG_mihDKf>ge1YL3Og;SscP9KW2dfPPSv;VUkuCr}nw9-(!h|G6XdJrs=n z;!3&~d2z>8bVY@iP<#w1PK-CeNQXg6#_;%6^vQ6&d+k-g%5LfRSJ97ylwo`gtUo7x zehnQ*mDxO_oTB)c5DbTuBy}b)OYR6DLr{T9Y$0xFRL(Yzk#lz^7(1y2Iw8Uf&L5v&eEB; z0YgJjqUjEL8}MG{4*E`nTvFXp`sG=G{l6vp>E(Q0CtGaDoFtz^v0SPNe|j5TiBH@| zZ>DF~)Y9%>(Qy(MIeI_Mm+>jxgdphMGPX|gF(b?4ISk3jQ(@>{wYG{5n8^k3(90uV)ebl~bIXb=AN6Z9f{WE?a-m&cA9=+)HdObS`0 zH=m$CKrkTi`Jhsr#0u74a3-4%&>V2bk{9X!ra-$t@OwCAE$+#~=~D@ZLPkw5j{FJE zspI&8Khga#dBrQha~28YdoNnrl2;tPqK8+DKYNwd?rVo>(;hE)*YE(Olb=l~nP{zq zX#Fy_#4(mnoNYi6#cG&{57?ztWhYN=7$hW=eMGq$a4A*LVsbqV^@93>dhNulelmxE zuYQeQTgw6WC>&vfC&d#YOw~>}hy@6i+A`@vlOLL7 z?Ggrp%AJObEufqVO>t;awn**~RjTq9$qgZBP_{^}W}&)})Qcd!_~F06nPd!q^cUI- z>a*jo^jeA!D9y-TR|Pqdk?&hEY7}1e}!*& zmtKRx@t%##-h+vUAX!ZApff$(F6GLkLYr+A4@+5 z8++bI^a_YP!1FbrkB)v!U%rG7EBE-xake|D?Hg>&pL)ulpV^|-VWHY$&_Q6$lVKD1 z%fG{RHsg(-&;y2^!fXS@&;7y?I8!t2@XaFmTB;R3+l9~Vf^sV)z4Z`eG*nG1e&rK- z9W^lH>%W?KGg(F+&`@w|D||-7hgb(>c{jF+3*Ob>^a*;&7Cr=vfrCgm1o72SVJ)7* zhY}GbE1vOjtd)%OaFde}4ofHKsyZLXOk{+VCm3ibWmLNhdFag{)4^pude1GO+DW+}TK*Zmed(A?j7~`MRfy4n4}C_j0!jPj zXS5T}(%*hYpFL-J7s<&>dnZ9kz`689HZgB+C@Ac<#g`W6m{ zJns0GZl4E{&zR8U6*ltt=ih>Tv)~WDrK=$_%j5)n=D&d-H+@H+GK&hpNn5RYgp|xI z)zwtmj-Q>SS}{kkvzxaWAfET}EY(TiJK-gBRJVfo-#Y+Ivm0Nm-Sd+G5H~9_UYIiddDId{%aVkTheJXJmp_CF3I(WP?^h zVmXllXzaHtX%-|ocewZ*rUh_a&s?Q4f=hbWDph1w$ja6vt#XRaj@2q3u)-CqRh?iS zK3=WLfN$+xqk{aA3=UVR`esM0B-=WLpQuu~=RxG&Z`BT4wJ~g1t7<~v>aJC-C&|4- z&glydNWlf>^ue9AX=VloWGk#yo!ko7=(;KS`#4Ai_E|~bA0~hSFbwXeAd>>Fx_7Zr zuu0l6VIxa^1Okr!0k}(nUb>Af1f{5GspX_tkp@DXqn{SyeI@L>XCxo}x zsQv*?;0BHAtp!ld-=s1^=BdQ)P?>9?{K=5&*Ned?*)^&ff=gU{L{Pl~)l^I+fdEPy z6RH}D;)7!1(sJxesdR9UcR@<^H2B{AX;lRUH+iwFYTxp_as@tpy|f`Ap`2yzB(txL>suy6@c&+@HW_?pFy599Hu6 z61ljV;_+AeRn_1%tv*Zjdlf9~lXFySp}Oz@k!B7X&jsW-Jan#VHxyT%tFi--56@L) zq2~E7)k9F!oTqvfH=VEY;XOZ7?Zj`Mueu4`+TG`?t_HUjiV`}@4FXpy{q<_q+F8(b=Z&g$SaYLlCG>~q zUC@8xMwJKpAHPxMTryxKN#CCOaV5W>$GdNWuI+g6Ce=>hReY0b3tG&N>87UBC0g;T zzfheA5fuN;Dt2+d%%#OFhE{y%%_IF>&2(?of@(Ozzvv8{v{eQvE|hqea-+ zQhIF>Hg42`__hc`7PxXOz#N9WAbjak^)PVFcT3f~;1>_}fCYh8C>uzLJC><;z}s_{ zsmY2TTc%Fa+lsS8T1`a$)e}Gkz{6r-oIdP_vtQ8id3jOmjjFk zbZU?v9>2Xot+BwhZ!AtO{8HpX60(tGAtg7@#g&+TY$bB$1ANG!EC+Ir@O6y3Qu1z8 zKT9q>HyPCpFw=-peI23!5cZ_0Ram=4eKIgp#};)L6xVK1L$12?$QJeU3n9bcLQXx8 zWExyyRzI*FxOX6`K0q#o-xkz=B;|c^^*Ev;qxg-TYK_#JR4=DN2u3pMQ{hVPhK#yV z4b`{C)x1)ijI9&u#mI*TCe)Wh(?2HECa9eJ~pZTIRf)HsosQ}r_{%w!@s7~ zeNc3qp=K&Gqa>~YGV%j`b@&YRK%ox`N-OrMgB19^`}V7i(0X*g+Kz^$@Aj+DLnw}+ ztiiYbM14IO@Kbfi^5IeCXA+3C1_VU`!kCr#jkDBC@vA>ouL4;A{HdC+i;vC_Pcz9C znU+?!SBp6MRieYN{HQ2xioRNnEzR`dm!X?E= zISCjr`wv*U{{S{MO*JPWqGEaAU{Iv`LVU;B>Mi)sREg{qy!GU&HIEOSqh1XMvRltl8{jPW#yRTEaF$zqK-~ju z6gZ&X3a7Lq2h>%|Lo>Jue)~)|r%xYHcUI&8IXSv8^8y62Z_ZWM>GD7J4hBp!W)alB zbJh2)7#n5GqkD7#fh3Yl6Vr_y|E0bR)(OQ)c>a0nzW~A~&Qo7qmK*&MBY)k`)D6qr zGg;atr$5D`s&ywjXDlO4mNArr8NY9!9N2ii`iw>}gpk|<3>(XV`63#MGn11T571x% zh3`bFZ3K9c31yJrXnc6SdZUgrLn3R?lZhzb2oMOh5dy*VeF%1G*9B@7D$D=aYI+Z< z8!KEy=%!~WO7kxcs+(b(K0K%%s&M>xm!U)IQ=rSmhtvVeYQ}4_s%_YCp}HKDWy^)? z`{9;QqAyZI8if_l71d*9qd&HqOGV(?j*HdH0MZ+xTEE=&Ln^|Ot1ea>mpgvsh38`R z_Qm|n{pd84kHS_;4_&N=Or=izXROYnPO1MgbsI%>m~qo3Ama~StDY}Cenfqb8c*D= zUS0*_PeM=-1k>rrrC0}1Ov=8DC_^L|(oUjY^M0;A7qqh^-mJC|!}G>1>TX!@reCUU zsvU_RSs{Sdr{GSK+)63XM-=F#UA9Pf-A=SGhgTj|>wpfxu;pbI(hDSa2D3Kf$WgTx zI$Uy8oq_K2?*hS6y5r?XROJ;UP^eLYY|3z8vMC*Y?U;HE1!O#O7nmU%)*MsUmeu|U zs(A32x?>Iy1BU6~V_-*_c=*v!nRndvq&2{Y3SEoP?H{6ncQ5$-H`ek*X#-D=wc6YO{KXCTFe2UpNADL!t6 O55LBxFYi`+XZ=4EDc3Im delta 22089 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 delta 104 zcmaFXCAzO&w4sHug{g(Pg=GtCs>1X_1y%{AKu5r#5S9LpjwVhzzRqE$F5CAju`X8v05p^xJ^%m! diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 62e66ed0a..dc33e4fc5 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -9,7 +9,6 @@ function preventTextHighlight(): void { } function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { - console.log(state) state.set('element', eventTargetElement); } @@ -55,8 +54,14 @@ export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { element.addEventListener('click', (event) => { + //Prevents shift+click from selecting table text + document.addEventListener('selectstart', preventTextHighlight) + //Stop propogation to avoid event firing multiple times event.stopPropagation(); - updatePreviousPkCheckState(event.target as HTMLInputElement, previousPkCheckState); + //Main logic for multi select + handlePkCheck(event, previousPkCheckState); + //Re-enables user's ability to select table text + document.removeEventListener('selectstart', preventTextHighlight) }); } } From 4e4f7f12f23568a75bd9055c3998b544a984f984 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 13:27:09 -0700 Subject: [PATCH 015/113] is this supposed to be ignored? --- netbox/project-static/dist/netbox.js.map | Bin 345446 -> 345447 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 8538f4c2a701fe35487ac56132d7e08be5a42f1f..61469e070456ceeedee6d1878b86d8a63390bc29 100644 GIT binary patch delta 92 zcmaFXCHlNew4sHug{g(Pg=Gutas}ReCmnZ3$4nO;XGdql?VA-?_cJoOPA^tsRZ@5L r14$)=2!9< Date: Thu, 5 May 2022 15:01:40 -0700 Subject: [PATCH 016/113] fixed text deselection and refactor --- netbox/project-static/dist/netbox.js | Bin 376041 -> 376078 bytes netbox/project-static/dist/netbox.js.map | Bin 345447 -> 345520 bytes .../src/buttons/selectMultiple.ts | 52 +++++++++--------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index b7095fa78873efc9370ca09754b6e9cf3b8641cb..ce2e0efd2ede7b7f41e1b1837cc1373283e0cee2 100644 GIT binary patch delta 1655 zcmaizdr(wW9LMLoyGLn!G?gw+BC*oQu5&YSv>By!1Vuy^L|9*#@p6}iyL&IY?5>ZM z)D%GxMGwnCAS^SbNRh63H7B3VsF{{&Qa&J`X}-&5vYDFBBG&jGg;(D}fF9oUo(4|O&LP)Kh!t%0n}+xR?CqLja% z1?18zANb&;GdEG>D|a>p0lV_<<_5q)BeyUV)7@KI;G%h3y(pmVTV?oZQgbx&6tP*< zVu8}$GDm|%I%>NQ*|cSQF*0dHYZ>M#n_3fr`Sj+FN3bfF+LD2I8l!B1g`QHrzyxLW z&KMw7*}1C|y^>>vVpnMU#V)>mJ>3!1(LL(h5S8#fZXlhm+?&Ix{<4>Zs6P074yT&7 z?^6wN#|l;#TNjD`wBP<^WKyg!r8{if&oEI*KA_id#5so+Uu?TcQT>q< zoaU7ysYp_Wf0?J{IO)-Sm`tY}i{>;-jx`}mdGWXj#O4;Hg02?Iw6Ds*xn4NYN+h=+ zKlw{Hf8EIeFFzSXq4M&nsUWgj$m^~s`=&}qO58$bcPyN}2a@X+tlja*a}65g({F!p z!l#&jT*~Rw1LqfV_+u_m4*%4JMz|F5;s9{^%P(E!+#MYOaNyTEWn?SXpWXl`UU}Ki ziEFNWq~*k$uh+p!<8Rbp3O#q@7{=3$KmUe!h2ET~<@C?rUd!nh-C4rv)7y6sBU#yg zFO%p*w-8U;?&+0*k5&*}x?2#tqJ57?>yV-R{+BllMQTwG)b)g0)%L~OzDQDO1U>;~ zvayj!LzY?#9+WSc+^${&JdKS%VKa*gIty~6n;3>_Mxx=KGiH>b|2l0 z1RLz{B|1)}v6(sRhwNR_*oZ$1g{DpaJ~R zsDTD;`_!<)h2{*8F~h;+d)of0d{=uIaH%8iYc2fjtBW6MGlqm&6G9rlJ*3f>-w)TR z_rpn+>p6d}C(0|u8NKY`IfM@~naz^V>0GqPD-HA5T(aL5?{sG5N(HtOqlqP#kui$} zNiqeTHgA+iG8g?1xPnazgbLxvb-X;{9)mM_}onz?27r%8}Q z0^2iUQ{aiA$L95Uq|#6@oa>&}JS~soR;9c}mxK3z9ZE|}?LH~rTgcZ}z!sH}HzN#u zAtjDNyEnzQ!1SbB95+l%G@1l)_iF1iYNfJB~?{8w;R*+&tS61SMUb#5kBNyiu zTGO>b;KsIZt zAghqaOe@JC_|>G9WHEXtibA$H$7GX?UWtj7EC&HL-UwDU)^3sqBXFx@79tucD9-fV&-bXFrFEz zNd$XX%{}AQ@7IxKTDBob!kHW-L)oPuw{H!SVO-Y{B$K!#rj{&;fQ2=GMjpbV)+w$Qbl&CibSz%yNCs;YV2;Zr^i2ocWmzf delta 1606 zcmZXTdr(wW9LMLoyGNPPXfj=xKmu`)b#4xrHlxlu0`+a}k-<;1m zck2`F?Z?`V`SnPU7u3HB#M4#VlaVC1Z(ju1=>zJ*o7B>fiAZ|3p&v_Vo!f@#6pbkp z$_j*-Je_`_D<+SIw@#(yjgeSRk2M}d8WmYAQsrLe2dtz6O~(*Jn|5SiDgA3l4_3&X zJJW!-s8>rc5~A7+517@+ zmIvBat58bk9x8%?wjHt|gZj5uAX(nsZUENN;f_Zrk*{<{15tE_yblrdocsl1<@dT~ z0E^|W?jcNw3m0sKp5xmJ`SG>1!>yq`${Iw=K0S88Ot&1#4fjqm+b zMGC@<NY?fGKqH(M`l2E-V`hJC8C%%F%sV6-S(TycA`0@c1Cd+1;OsRH{93hSR)$ zA`UTf*q7OAj+373#R|IYR4}LMJk^RsdD3YeNP4&+<~|)Er+!rd&h^rnb|TpY>u8ql z{d$N4-h9>#hy2R9M38*DkUiE>@lCCUSnWdcSS)-$0^+m_DP!@Ri_I!n=(j&wkR$7V z+Q{kCzQHmMfBq%P;h($I0-J2U>BsZ0Fm))+YX14*$ARRzGB*m$^7T2ay5g0D#g{T z9!T_ZlIk(M zr9g`qXf*{|MV*T)EF$l5jPALN_lN0Rqxw;sa!(Le&ZT4JkPzN9D<{vGV>YhUi_f+? z;i;GSKd(ui;m?_sv(PGHV?)vK3v(JR#vI8hWjUm2g2A8@xT90D+7cXATUKtCly4P7 zbwZ&zR1^ghFEj4~O@R0FUF=8|NqH&Pte+N}pPMHI##z@IrwPR@pqeZ~Hp{3cVJKyF z)nqmd>_jzr7sV`o3z>>s#j=I0hi|D_Fql{CjAE!H>dH+;Vvc8SUVX z4VljRYRC<4xaU36s#<4e;T>cOo3&9hj;*OB;mBZhwIuB2WV7xWYstfU)jH)uEeTe! zh+3j$6YI!4q_8J-#Ghr?5ylL4WGd6vbLS}KNj=%5MhWY4b7g~@%w*+m;=_J$lh@c$ zPtLcIyv}v8jbwvABG}o_$U{UZeKOGkvFu_O>EcUGH+dV2*>E>0#9}4wFzFfhFCOe! AJpcdz diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 61469e070456ceeedee6d1878b86d8a63390bc29..9814304071db1bcd00884cef28580bc2cee1a997 100644 GIT binary patch delta 312 zcmX9(yH3ME5EPI50E!eKglr4CZ7jqi6`EZ;Ckr{oK`2U1Lxdof!b_&1a78}gK7p1Z zh?XCr<~O*#G^5q*?9A-nd+&46d+XM^^=_lvT$-EcU?wCOHbNDuoW?*!QwA>R5_n{~ zW!RQo6Pfhvn8^ZjQG5>Q8G$?$$`n+1S|xJC-VHUCz*yOdSCuF3D>kWnjm_zvyacP7 zYT!zb@7Ry_DVSRK8ynv4GfhH;)!O4U)3nwtBa`n65Y(kFDkGm)47||*bdKC@#iqY= spQ-aKB&~4HzKv}&7bj-4FNdOHsH)UQWvJt|LPe#dS;OByiTOzW0i(87L;wH) delta 239 zcmdncE&9Amw4sHug{g(Pg=GtCh5|>ivyQ8yXNmLlUIkV|;Zi3ZcSpy3Fq!G1Jy?_A)d|b zf^;}Lx@LeCxKCGAWOZcnH=O<#Xxa2$MOI6uGN }; -function preventTextHighlight(): void { - return +function removeTextSelection(): void{ + window.getSelection()?.removeAllRanges(); } function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { state.set('element', eventTargetElement); } -function handlePkCheck(event: _MouseEvent, state: StateManager): void { - const eventTargetElement = event.target as HTMLInputElement; - const previousStateElement = state.get('element'); - updatePreviousPkCheckState(eventTargetElement, state); - //Stop if user is not holding shift key - if(event.shiftKey === false){ - return - } - //If no previous state, store event target element as previous state and return - if (previousStateElement === null) { - return updatePreviousPkCheckState(eventTargetElement, state); - } - const checkboxList = getElements('input[type="checkbox"][name="pk"]'); - let changePkCheckboxState = false; - for(const element of checkboxList){ +function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, elementList: Generator): void{ + let changePkCheckboxState = false + for(let element of elementList){ + //Change loop's current checkbox state to eventTargetElement checkbox state + if(changePkCheckboxState === true){ + element.checked = eventTargetElement.checked; + } //The previously clicked checkbox was above the shift clicked checkbox if(element === previousStateElement){ if(changePkCheckboxState === true){ @@ -34,9 +26,6 @@ function handlePkCheck(event: _MouseEvent, state: StateManager): void { + const eventTargetElement = event.target as HTMLInputElement; + const previousStateElement = state.get('element'); + updatePreviousPkCheckState(eventTargetElement, state); + //Stop if user is not holding shift key + if(!event.shiftKey){ + return + } + removeTextSelection(); + //If no previous state, store event target element as previous state and return + if (previousStateElement === null) { + return updatePreviousPkCheckState(eventTargetElement, state); + } + const checkboxList = getElements('input[type="checkbox"][name="pk"]'); + toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList) +} + export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { element.addEventListener('click', (event) => { - //Prevents shift+click from selecting table text - document.addEventListener('selectstart', preventTextHighlight) + removeTextSelection() //Stop propogation to avoid event firing multiple times event.stopPropagation(); - //Main logic for multi select handlePkCheck(event, previousPkCheckState); - //Re-enables user's ability to select table text - document.removeEventListener('selectstart', preventTextHighlight) }); } } From 2c314c454e0fdbb83e3383dc93ae10e147575a62 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Thu, 5 May 2022 15:24:16 -0700 Subject: [PATCH 017/113] Fixed variable type issue...i think. --- netbox/project-static/src/buttons/selectMultiple.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 0ec19672c..8d75fb866 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -14,10 +14,11 @@ function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, elementList: Generator): void{ let changePkCheckboxState = false - for(let element of elementList){ + for(const element of elementList){ + const typedElement = element as HTMLInputElement //Change loop's current checkbox state to eventTargetElement checkbox state if(changePkCheckboxState === true){ - element.checked = eventTargetElement.checked; + typedElement.checked = eventTargetElement.checked; } //The previously clicked checkbox was above the shift clicked checkbox if(element === previousStateElement){ @@ -26,7 +27,7 @@ function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousState return } changePkCheckboxState = true; - element.checked = eventTargetElement.checked; + typedElement.checked = eventTargetElement.checked; } //The previously clicked checkbox was below the shift clicked checkbox if(element === eventTargetElement){ From 68ad7273bf9f8dfffe851947c316f3649a33edc1 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 11:33:00 -0700 Subject: [PATCH 018/113] various punctuation and spacing fixes --- .../src/buttons/selectMultiple.ts | 49 +++++++++++-------- netbox/project-static/src/stores/index.ts | 2 +- .../src/stores/previousPkCheck.ts | 5 +- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 8d75fb866..8a5d2aabb 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -4,36 +4,43 @@ import { previousPkCheckState } from '../stores'; type PreviousPkCheckState = { element: Nullable }; -function removeTextSelection(): void{ +function removeTextSelection(): void { window.getSelection()?.removeAllRanges(); } -function updatePreviousPkCheckState(eventTargetElement: HTMLInputElement, state: StateManager): void { +function updatePreviousPkCheckState( + eventTargetElement: HTMLInputElement, + state: StateManager, +): void { state.set('element', eventTargetElement); } -function toggleCheckboxRange(eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, elementList: Generator): void{ - let changePkCheckboxState = false - for(const element of elementList){ - const typedElement = element as HTMLInputElement +function toggleCheckboxRange( + eventTargetElement: HTMLInputElement, + previousStateElement: HTMLInputElement, + elementList: Generator, +): void { + let changePkCheckboxState = false; + for (const element of elementList) { + const typedElement = element as HTMLInputElement; //Change loop's current checkbox state to eventTargetElement checkbox state - if(changePkCheckboxState === true){ + if (changePkCheckboxState === true) { typedElement.checked = eventTargetElement.checked; } - //The previously clicked checkbox was above the shift clicked checkbox - if(element === previousStateElement){ - if(changePkCheckboxState === true){ + //The previously clicked checkbox was above the shift clicked checkbox + if (element === previousStateElement) { + if (changePkCheckboxState === true) { changePkCheckboxState = false; - return + return; } changePkCheckboxState = true; typedElement.checked = eventTargetElement.checked; } - //The previously clicked checkbox was below the shift clicked checkbox - if(element === eventTargetElement){ - if(changePkCheckboxState === true){ - changePkCheckboxState = false - return + //The previously clicked checkbox was below the shift clicked checkbox + if (element === eventTargetElement) { + if (changePkCheckboxState === true) { + changePkCheckboxState = false; + return; } changePkCheckboxState = true; } @@ -45,8 +52,8 @@ function handlePkCheck(event: MouseEvent, state: StateManager('input[type="checkbox"][name="pk"]'); - toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList) + toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList); } export function initSelectMultiple(): void { const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { - element.addEventListener('click', (event) => { - removeTextSelection() + element.addEventListener('click', event => { + removeTextSelection(); //Stop propogation to avoid event firing multiple times event.stopPropagation(); handlePkCheck(event, previousPkCheckState); diff --git a/netbox/project-static/src/stores/index.ts b/netbox/project-static/src/stores/index.ts index 5e53410ad..d4644e619 100644 --- a/netbox/project-static/src/stores/index.ts +++ b/netbox/project-static/src/stores/index.ts @@ -1,3 +1,3 @@ export * from './objectDepth'; export * from './rackImages'; -export * from './previousPkCheck'; \ No newline at end of file +export * from './previousPkCheck'; diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts index a5d06ceee..19b244ec7 100644 --- a/netbox/project-static/src/stores/previousPkCheck.ts +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -1,7 +1,6 @@ import { createState } from '../state'; export const previousPkCheckState = createState<{ element: Nullable }>( - { element: null}, - { persist: false } + { element: null }, + { persist: false }, ); - From 25aaa6ec2b592c343ee6112e50d0ecdf2426102e Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 11:43:18 -0700 Subject: [PATCH 019/113] added JSDoc comments --- .../src/buttons/selectMultiple.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index 8a5d2aabb..d05c21716 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -4,10 +4,20 @@ import { previousPkCheckState } from '../stores'; type PreviousPkCheckState = { element: Nullable }; +/** + * If there is a text selection, removes it. + */ function removeTextSelection(): void { window.getSelection()?.removeAllRanges(); } +/** + * Sets the state object passed in to the eventTargetElement object passed in. + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ function updatePreviousPkCheckState( eventTargetElement: HTMLInputElement, state: StateManager, @@ -15,6 +25,14 @@ function updatePreviousPkCheckState( state.set('element', eventTargetElement); } +/** + * For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle + * "checked" value to eventTargetElement.checked + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ function toggleCheckboxRange( eventTargetElement: HTMLInputElement, previousStateElement: HTMLInputElement, @@ -47,6 +65,14 @@ function toggleCheckboxRange( } } + +/** + * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the + * event target element and the state element. + * + * @param event Mouse event. + * @param state PreviousPkCheckState object. + */ function handlePkCheck(event: MouseEvent, state: StateManager): void { const eventTargetElement = event.target as HTMLInputElement; const previousStateElement = state.get('element'); @@ -64,6 +90,9 @@ function handlePkCheck(event: MouseEvent, state: StateManager('input[type="checkbox"][name="pk"]'); for (const element of checkboxElements) { From b6a0751d4ff8f6ca1c5c6d5df1a0080a1d8d0e50 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 11:44:34 -0700 Subject: [PATCH 020/113] prettier fixes --- .../project-static/src/buttons/selectMultiple.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts index d05c21716..d8bad3105 100644 --- a/netbox/project-static/src/buttons/selectMultiple.ts +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -13,9 +13,9 @@ function removeTextSelection(): void { /** * Sets the state object passed in to the eventTargetElement object passed in. - * + * * @param eventTargetElement HTML Input Element, retrieved from getting the target of the - * event passed in from handlePkCheck() + * event passed in from handlePkCheck() * @param state PreviousPkCheckState object. */ function updatePreviousPkCheckState( @@ -27,10 +27,10 @@ function updatePreviousPkCheckState( /** * For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle - * "checked" value to eventTargetElement.checked - * + * "checked" value to eventTargetElement.checked + * * @param eventTargetElement HTML Input Element, retrieved from getting the target of the - * event passed in from handlePkCheck() + * event passed in from handlePkCheck() * @param state PreviousPkCheckState object. */ function toggleCheckboxRange( @@ -65,11 +65,10 @@ function toggleCheckboxRange( } } - /** - * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the + * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the * event target element and the state element. - * + * * @param event Mouse event. * @param state PreviousPkCheckState object. */ From 15660287981c5fc83c78a031157c63ad73148f83 Mon Sep 17 00:00:00 2001 From: CroogQT Date: Fri, 6 May 2022 12:16:45 -0700 Subject: [PATCH 021/113] yarn bundle. --- netbox/project-static/dist/netbox.js | Bin 376078 -> 376088 bytes netbox/project-static/dist/netbox.js.map | Bin 345520 -> 345522 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index ce2e0efd2ede7b7f41e1b1837cc1373283e0cee2..ce02d4bbb227926941d2adc3ae38e721d48ce21b 100644 GIT binary patch delta 57 zcmeDCB{t)iSVIeA3sVbo3(FSPhpiGhsU-@DdA9j^)|qJX&Oa&wzjs425M>=`O|;4vf44`Z?|Y;eP9Lv DuRjqe diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 9814304071db1bcd00884cef28580bc2cee1a997..e21571e0c84f1680154cb438612199fef77a0f4e 100644 GIT binary patch delta 377 zcmY+AJxc>Y5QfReBG_3(un}_>HdBa_BB0>AOLCsaCWhoV4ne%aLXD^qja;L}bpgR@ zOKUq3!GGXi@ozYDeuOm3?#%N(Gwkc6_BN?K}r=eNR zA^Vd=D$uDB$_jPNH#-t^FQ5=u*Ibnp_fqNsk)Y?A7xM4cj39|ruC<6R3(;S&aB_9% z!n*RdXW~8hjVW9xE(*nPTIJa@ils`3#C_J)!NR8e6Mon3!wlSprw&WjJ$Y)^J#Q&h T<-wWz^3-y<+5VVYRWIH*)~sdF delta 390 zcmY+Ay-EW?6opB2iZlU>6e37=ZEv9j6$^_q6Pc{bx~{T>MF`p%qp0`?+r(nKJiz!0 zb}E8TVe32i7T!BSLYm=n?)lC=%*UklHfcRqwW?k%REwYLv^~DCrJ*z6+K|D)jiD6j z9`%8gh8j4eW8gv4NW+$z3U9Q)ogsdQU4~5AlR2RXh$sj8JlRKY-qNy~nx8zv3M2QC z=n+Nkr>&ZUjFKsag15=GQ2Ul*U|n;V>Bx(WG9VZ5N^>pCW!X`PaO+bq{e^#y Date: Wed, 11 May 2022 16:13:35 -0400 Subject: [PATCH 022/113] 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 60a504946138877a3ed422a56b84dcca53926560 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 16:22:07 -0400 Subject: [PATCH 023/113] 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 6c15ea66b99bfd35f64181d5d3280b358ecb5dca Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 14:14:40 -0400 Subject: [PATCH 024/113] PRVB --- docs/release-notes/version-3.2.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 0c56c92f7..408d572c7 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,9 @@ # NetBox v3.2 +## v3.2.4 (FUTURE) + +--- + ## v3.2.3 (2022-05-12) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 999e39479..59306b8fa 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.2.3' +VERSION = '3.2.4-dev' # Hostname HOSTNAME = platform.node() From 39fc13f005b6c0926d5fff70fea90ce86484804e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:08:00 -0500 Subject: [PATCH 025/113] Fixes #9094 - Fix partial address search within Prefix and Aggregate filters --- docs/release-notes/version-3.2.md | 5 +++++ netbox/ipam/filtersets.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 408d572c7..df7436e04 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,11 @@ ## v3.2.4 (FUTURE) +### Bug Fixes + +* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters + + --- ## v3.2.3 (2022-05-12) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 7839dc03e..3416e72eb 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -148,6 +148,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -337,6 +338,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) From d31a8b1594f4e882b5ab95a107e2d9c950446e83 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:08:00 -0500 Subject: [PATCH 026/113] Fixes #9094 - Fix partial address search within Prefix and Aggregate filters --- docs/release-notes/version-3.2.md | 5 +++++ netbox/ipam/filtersets.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 408d572c7..df7436e04 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,11 @@ ## v3.2.4 (FUTURE) +### Bug Fixes + +* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters + + --- ## v3.2.3 (2022-05-12) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 7839dc03e..bdb7c463d 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -145,6 +145,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) @@ -334,6 +335,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) From 9410af47c91829d60612b55734e548da4911ac1a Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:40:24 -0500 Subject: [PATCH 027/113] Fixes #8922 - Add service list to IP address view --- docs/release-notes/version-3.2.md | 4 ++++ netbox/ipam/views.py | 3 +++ netbox/templates/ipam/ipaddress.html | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index df7436e04..ef68aab09 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,10 @@ ## v3.2.4 (FUTURE) +### Enhancements + +* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view + ### Bug Fixes * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 79804aabd..d5c1e670e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -674,11 +674,14 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) + services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance) + return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, 'more_duplicate_ips': duplicate_ips.count() > 10, 'related_ips_table': related_ips_table, + 'services': services, } diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7867e829b..ab47c11af 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -128,6 +128,24 @@
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
+
+
+ Services +
+
+ {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
+ {% else %} +
+ None +
+ {% endif %} +
+
{% plugin_right_page object %}
From cea05b0809148c09db54fe32a97f7dff084d63e6 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:49:07 -0500 Subject: [PATCH 028/113] Fixes #8374 - Display device type and asset tag if name is blank but asset tag is populated --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/models/devices.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ef68aab09..46a22fb6c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,6 +4,7 @@ ### Enhancements +* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view ### Bug Fixes diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8d50db958..e88af2d05 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel): return f'{self.name} ({self.asset_tag})' elif self.name: return self.name + elif self.virtual_chassis and self.asset_tag: + return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})' elif self.virtual_chassis: return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})' + elif self.device_type and self.asset_tag: + return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})' elif self.device_type: return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return super().__str__() From 6ea4d95201718fa33a6774dbb7b19642854f446e Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 14 May 2022 12:01:49 +0200 Subject: [PATCH 029/113] Fix provider table in ASN view when ordering by circuit_count --- netbox/ipam/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d5c1e670e..84f6db6d5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -4,7 +4,7 @@ from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from circuits.models import Provider +from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site @@ -225,7 +225,9 @@ class ASNView(generic.ObjectView): sites_table.configure(request) # Gather assigned Providers - providers = instance.providers.restrict(request.user, 'view') + providers = instance.providers.restrict(request.user, 'view').annotate( + count_circuits=count_related(Circuit, 'provider') + ) providers_table = ProviderTable(providers, user=request.user) providers_table.configure(request) From be7e7de8aa21d7f939e9358241f27059f64834d3 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 14 May 2022 17:48:37 +0200 Subject: [PATCH 030/113] Add contact_group to ContactModelFilterSet --- netbox/circuits/forms/filtersets.py | 4 ++-- netbox/dcim/forms/filtersets.py | 10 +++++----- netbox/tenancy/filtersets.py | 6 ++++++ netbox/tenancy/forms/forms.py | 5 +++++ netbox/virtualization/forms/filtersets.py | 4 ++-- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index ca3b003b9..46d3824bb 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ('Attributes', ('type_id', 'status', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0c7d02f9d..9a41e71cb 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte (None, ('q', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) status = MultipleChoiceField( choices=SiteStatusChoices, @@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF (None, ('q', 'tag')), ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -214,7 +214,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -518,7 +518,7 @@ class DeviceFilterForm( ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), @@ -788,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 8ca4ae29c..dd14a412b 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet): queryset=ContactRole.objects.all(), label='Contact Role' ) + contact_group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='contacts__contact__group', + lookup_expr='in', + label='Contact group', + ) # diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 5dcad1d43..5e78bc540 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form): required=False, label=_('Contact Role') ) + contact_group = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Contact Group') + ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 2f386e889..670729d56 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -38,7 +38,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ('Attributes', ('group_id', 'type_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), @@ -87,7 +87,7 @@ class VirtualMachineFilterForm( ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), From b01db8130ca1ba670f89ea48221714cf9176144c Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 14 May 2022 17:53:40 +0200 Subject: [PATCH 031/113] Added contact_group to region, site, manufacturer, tenant filters --- netbox/dcim/forms/filtersets.py | 6 +++--- netbox/tenancy/forms/filtersets.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 9a41e71cb..6998b40e8 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -329,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( (None, ('q', 'tag')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 15d7773b7..02589d733 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( (None, ('q', 'tag', 'group_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), From 22f3662352d8d010154c86a271b7d4f21f18cb3c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 16 May 2022 09:55:17 -0400 Subject: [PATCH 032/113] #9239: Organize contact form fields --- netbox/virtualization/forms/filtersets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 670729d56..88aa1a6c2 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -29,6 +29,10 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm): class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) + fieldsets = ( + (None, ('q', 'tag')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), + ) class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): From 10605ebd4c8227bb258ca631fa012c9bcbefc2c0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 16 May 2022 09:56:02 -0400 Subject: [PATCH 033/113] Changelog for #9239, #9358 --- docs/release-notes/version-3.2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 46a22fb6c..991972899 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,10 +6,12 @@ * [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view +* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment ### Bug Fixes * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters +* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view --- From 3ee76548e058dc661245cf751ddba8bb10f80857 Mon Sep 17 00:00:00 2001 From: bluikko <14869000+bluikko@users.noreply.github.com> Date: Wed, 18 May 2022 15:08:08 +0700 Subject: [PATCH 034/113] Add other power, front/rear port types Fixes #9098 --- netbox/dcim/choices.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index a89960457..2e96f9c67 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -354,6 +354,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -471,6 +472,7 @@ class PowerPortTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -580,6 +582,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -690,6 +693,7 @@ class PowerOutletTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -1047,6 +1051,7 @@ class PortTypeChoices(ChoiceSet): TYPE_URM_P2 = 'urm-p2' TYPE_URM_P4 = 'urm-p4' TYPE_URM_P8 = 'urm-p8' + TYPE_OTHER = 'other' CHOICES = ( ( @@ -1099,6 +1104,12 @@ class PortTypeChoices(ChoiceSet): (TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P8, 'URM-P8'), (TYPE_SPLICE, 'Splice'), + ), + ), + ( + 'Other', + ( + (TYPE_OTHER, 'Other'), ) ) ) From 23dae6b99ff16f57d1d2982ae19f19ce9d9ba1f7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 18 May 2022 08:42:20 -0400 Subject: [PATCH 035/113] Changelog for #9098 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 991972899..14e2639f4 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,6 +6,7 @@ * [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view +* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment ### Bug Fixes From 2d933314af7eaa15e18cc6ba532a388336dbbac2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 19 May 2022 16:13:22 -0400 Subject: [PATCH 036/113] Closes #8471: Add status field to Cluster --- docs/models/virtualization/cluster.md | 2 +- docs/release-notes/version-3.3.md | 3 +++ netbox/virtualization/api/serializers.py | 5 +++-- netbox/virtualization/choices.py | 22 +++++++++++++++++++ netbox/virtualization/filtersets.py | 4 ++++ netbox/virtualization/forms/bulk_edit.py | 8 ++++++- netbox/virtualization/forms/bulk_import.py | 6 ++++- netbox/virtualization/forms/filtersets.py | 6 ++++- netbox/virtualization/forms/models.py | 8 +++++-- .../migrations/0030_cluster_status.py | 18 +++++++++++++++ netbox/virtualization/models.py | 8 +++++++ netbox/virtualization/tables/clusters.py | 7 +++--- netbox/virtualization/tests/test_api.py | 11 +++++++--- .../virtualization/tests/test_filtersets.py | 10 ++++++--- netbox/virtualization/tests/test_views.py | 16 ++++++++------ 15 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 netbox/virtualization/migrations/0030_cluster_status.py diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 7fc9bfc06..3e3516cd6 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,5 @@ # Clusters -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c46fceea5..d1b6b4cda 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#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 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results @@ -23,3 +24,5 @@ * 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 +* virtualization.Cluster + * Add required `status` field (default value: `active`) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index afdf50b96..e127bd5fa 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -45,6 +45,7 @@ class ClusterSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) + status = ChoiceField(choices=ClusterStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) @@ -53,8 +54,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 693e53df6..2cf6357e1 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -1,6 +1,28 @@ from utilities.choices import ChoiceSet +# +# Clusters +# + +class ClusterStatusChoices(ChoiceSet): + key = 'Cluster.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] + + # # VirtualMachines # diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 5a2aa8b42..63e3557a3 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label='Cluster type (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=ClusterStatusChoices, + null_value=None + ) class Meta: model = Cluster diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d5d33df2a..e7369f53a 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=ClusterGroup.objects.all(), required=False ) + status = forms.ChoiceField( + choices=add_blank_choice(ClusterStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'tenant',)), + (None, ('type', 'group', 'status', 'tenant',)), ('Site', ('region', 'site_group', 'site',)), ) nullable_fields = ( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index eab6fc9e7..ef688367e 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm): required=False, help_text='Assigned cluster group' ) + status = CSVChoiceField( + choices=ClusterStatusChoices, + help_text='Operational status' + ) site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'comments') class VirtualMachineCSVForm(NetBoxModelCSVForm): diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 2f386e889..753f509f7 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -35,7 +35,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi model = Cluster fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('group_id', 'type_id')), + ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role')), @@ -50,6 +50,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi required=False, label=_('Region') ) + status = MultipleChoiceField( + choices=ClusterStatusChoices, + required=False + ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 314b0bddf..a94cc3920 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) + widgets = { + 'status': StaticSelect(), + } class ClusterAddDevicesForm(BootstrapMixin, forms.Form): diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py new file mode 100644 index 000000000..e836bb914 --- /dev/null +++ b/netbox/virtualization/migrations/0030_cluster_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-19 19:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0029_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 586bb8a9e..afc450ddd 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -119,6 +119,11 @@ class Cluster(NetBoxModel): blank=True, null=True ) + status = models.CharField( + max_length=50, + choices=ClusterStatusChoices, + default=ClusterStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -165,6 +170,9 @@ class Cluster(NetBoxModel): def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) + def get_status_color(self): + return ClusterStatusChoices.colors.get(self.status) + def clean(self): super().clean() diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index a0c98425a..dfcae052a 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -66,6 +66,7 @@ class ClusterTable(NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = tables.Column( linkify=True ) @@ -93,7 +94,7 @@ class ClusterTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count', + 'contacts', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') + default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index f6c07fa54..4d559dc49 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -4,6 +4,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases +from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -85,6 +86,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { + 'status': 'offline', 'comments': 'New comment', } @@ -104,9 +106,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): ClusterGroup.objects.bulk_create(cluster_groups) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), ) Cluster.objects.bulk_create(clusters) @@ -115,16 +117,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): 'name': 'Cluster 4', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 5', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 6', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 9e264ac5c..8b4e79bed 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]), - Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]), - Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]), + Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]), + Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]), ) Cluster.objects.bulk_create(clusters) @@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'group': [groups[0].slug, groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): types = ClusterType.objects.all()[:2] params = {'type_id': [types[0].pk, types[1].pk]} diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 8edc14f00..df90bfc37 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ClusterType.objects.bulk_create(clustertypes) Cluster.objects.bulk_create([ - Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]), + Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Cluster X', 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'Some comments', @@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,type", - "Cluster 4,Cluster Type 1", - "Cluster 5,Cluster Type 1", - "Cluster 6,Cluster Type 1", + "name,type,status", + "Cluster 4,Cluster Type 1,active", + "Cluster 5,Cluster Type 1,active", + "Cluster 6,Cluster Type 1,active", ) cls.bulk_edit_data = { 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'New comments', From 29a1bf6cb1ac98de32ff4594b5de0e26ef23c9dd Mon Sep 17 00:00:00 2001 From: lastorel Date: Sun, 22 May 2022 17:22:28 +0300 Subject: [PATCH 037/113] add role attribute to filter inventoryitems --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 6998b40e8..1535e5718 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1102,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), + ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) role_id = DynamicModelMultipleChoiceField( From e4f967e7c6a15276789cadd13021167536af5471 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Tue, 24 May 2022 10:12:32 +0200 Subject: [PATCH 038/113] #9166 - Add UI Visibility setting for custom fields --- netbox/extras/api/serializers.py | 3 ++- netbox/extras/choices.py | 13 +++++++++++++ netbox/extras/filtersets.py | 4 +++- netbox/extras/forms/bulk_edit.py | 7 +++++++ netbox/extras/forms/bulk_import.py | 2 +- netbox/extras/forms/customfields.py | 7 +++++++ netbox/extras/forms/filtersets.py | 8 +++++++- netbox/extras/forms/models.py | 3 ++- .../0075_customfield_ui_visibility.py | 18 ++++++++++++++++++ netbox/extras/models/customfields.py | 6 ++++++ netbox/extras/tables/tables.py | 3 ++- netbox/extras/tests/test_views.py | 9 +++++---- netbox/netbox/models/features.py | 10 +++++++--- netbox/templates/extras/customfield.html | 4 ++++ 14 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 netbox/extras/migrations/0075_customfield_ui_visibility.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index eed7f7603..1a26faec1 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() + ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) class Meta: model = CustomField fields = [ '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', + 'validation_regex', 'choices', 'created', 'last_updated', 'ui_visibility', ] def get_data_type(self, obj): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index f14368d3d..123fd2cd4 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet): ) +class CustomFieldVisibilityChoices(ChoiceSet): + + VISIBILITY_READ_WRITE = 'read-write' + VISIBILITY_READ_ONLY = 'read-only' + VISIBILITY_HIDDEN = 'hidden' + + CHOICES = ( + (VISIBILITY_READ_WRITE, 'Read/Write'), + (VISIBILITY_READ_ONLY, 'Read-only'), + (VISIBILITY_HIDDEN, 'Hidden'), + ) + + # # CustomLinks # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 467ae23af..ea74dfc82 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -62,7 +62,9 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description'] + fields = [ + 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description', 'ui_visibility' + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b722bd751..b1d8a6c21 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -37,6 +37,13 @@ class CustomFieldBulkEditForm(BulkEditForm): weight = forms.IntegerField( required=False ) + ui_visibility = forms.ChoiceField( + label="UI visibility", + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + initial='', + widget=StaticSelect() + ) nullable_fields = ('group_name', 'description',) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index dabf2f811..c0483d36e 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -37,7 +37,7 @@ class CustomFieldCSVForm(CSVModelForm): model = CustomField fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', - 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'ui_visibility', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index bb8028eec..c4496c5f8 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from extras.models import * +from extras.choices import CustomFieldVisibilityChoices __all__ = ( 'CustomFieldsMixin', @@ -42,8 +43,14 @@ class CustomFieldsMixin: Append form fields for all CustomFields assigned to this object type. """ for customfield in self._get_custom_fields(self._get_content_type()): + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + self.fields[field_name].disabled = True + # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 1710ecb89..cd59a9db1 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', ('content_types', 'type', 'group_name', 'weight', 'required')), + ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), @@ -56,6 +56,12 @@ class CustomFieldFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + ui_visibility = forms.ChoiceField( + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + label=_('UI Visibility'), + widget=StaticSelect() + ) class CustomLinkFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index b07853f86..16874c49e 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -41,7 +41,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): fieldsets = ( ('Custom Field', ( - 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', 'ui_visibility', )), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), @@ -58,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): widgets = { 'type': StaticSelect(), 'filter_logic': StaticSelect(), + 'ui_visibility': StaticSelect(), } diff --git a/netbox/extras/migrations/0075_customfield_ui_visibility.py b/netbox/extras/migrations/0075_customfield_ui_visibility.py new file mode 100644 index 000000000..29ee65516 --- /dev/null +++ b/netbox/extras/migrations/0075_customfield_ui_visibility.py @@ -0,0 +1,18 @@ +# 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/extras/models/customfields.py b/netbox/extras/models/customfields.py index 55caa4a70..c48b6895c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -136,6 +136,12 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) + ui_visibility = models.CharField( + max_length=50, + choices=CustomFieldVisibilityChoices, + default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + help_text='Specifies the visibility of custom field in the UI.' + ) objects = CustomFieldManager() class Meta: diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 1a0f5d58a..d294fd231 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -28,12 +28,13 @@ class CustomFieldTable(NetBoxTable): ) content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() + ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', 'created', 'last_updated', + 'description', 'filter_logic', 'choices', 'created', 'last_updated', 'ui_visibility', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ea3a952d6..0a9d85e15 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -36,13 +36,14 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'default': None, 'weight': 200, 'required': True, + 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, } cls.csv_data = ( - 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', - 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', - 'field5,Field 5,integer,dcim.site,100,exact,,1,100,', - 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', + 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3},read-write', + 'field5,Field 5,integer,dcim.site,100,exact,,1,100,,read-write', + 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,,read-write', ) cls.bulk_edit_data = { diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 4bd1b0e9c..76b546192 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -9,7 +9,7 @@ from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager -from extras.choices import ObjectChangeActionChoices +from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import register_features from netbox.signals import post_clean from utilities.utils import serialize_object @@ -100,7 +100,7 @@ class CustomFieldsMixin(models.Model): """ return self.custom_field_data - def get_custom_fields(self): + def get_custom_fields(self, omit_hidden=False): """ Return a dictionary of custom fields for a single object in the form `{field: value}`. @@ -114,6 +114,10 @@ class CustomFieldsMixin(models.Model): data = {} for field in CustomField.objects.get_for_model(self): + # Skip fields that are hidden if 'omit_hidden' is set + if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + value = self.custom_field_data.get(field.name) data[field] = field.deserialize(value) @@ -124,7 +128,7 @@ class CustomFieldsMixin(models.Model): 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(): + for cf, value in self.get_custom_fields(omit_hidden=True).items(): grouped_custom_fields[cf.group_name][cf] = value return dict(grouped_custom_fields) diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index dc51d3e82..72dc2e4c3 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -42,6 +42,10 @@ Weight {{ object.weight }} + + UI Visibility + {{ object.get_ui_visibility_display }} + From 106d40f009a50fbbbdc2926e7a0639bfb2d07803 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Tue, 24 May 2022 10:27:29 +0200 Subject: [PATCH 039/113] Exclude hidden custom fields from tables --- netbox/netbox/tables/tables.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 5ebb78865..66f5e1f7e 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink +from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -178,7 +179,10 @@ class NetBoxTable(BaseTable): # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(content_types=content_type) + custom_fields = CustomField.objects.filter( + content_types=content_type + ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) + extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) From 1e099703b5114b8bbe574dabf6b0c646ebb61c92 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Tue, 24 May 2022 10:38:55 +0200 Subject: [PATCH 040/113] Remove whitespace from blank line --- netbox/netbox/tables/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 66f5e1f7e..38399b5fe 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -182,7 +182,7 @@ class NetBoxTable(BaseTable): custom_fields = CustomField.objects.filter( content_types=content_type ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) - + extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) From 1a48f4497b672e0f9ba4af27ec7ad2f67be24fd5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 08:39:43 -0400 Subject: [PATCH 041/113] Bump stale to v5 --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8099923f..7390ec1df 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,7 +8,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v4 + - uses: actions/stale@v5 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an From f7495758e9ed9988a79fe7e45343da9e2278e394 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:14:25 -0400 Subject: [PATCH 042/113] Fixes #9387: Ensure ActionsColumn extra_buttons are always displayed --- docs/release-notes/version-3.2.md | 1 + netbox/netbox/tables/columns.py | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 14e2639f4..7c5e454ef 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -13,6 +13,7 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view +* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed --- diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 801b97766..0c26e541e 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -192,32 +192,35 @@ class ActionsColumn(tables.Column): model = table.Meta.model request = getattr(table, 'context', {}).get('request') url_appendix = f'?return_url={request.path}' if request else '' + html = '' + # Compile actions menu links = [] user = getattr(request, 'user', AnonymousUser()) for action, attrs in self.actions.items(): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append(f'
  • ' - f' {attrs.title}
  • ') - - if not links: - return '' - - menu = f'' \ - f'' \ - f'' \ - f'' + links.append( + f'
  • ' + f' {attrs.title}
  • ' + ) + if links: + html += ( + f'' + f'' + f'' + f'' + ) # Render any extra buttons from template code if self.extra_buttons: template = Template(self.extra_buttons) context = getattr(table, "context", Context()) context.update({'record': record}) - menu = template.render(context) + menu + html = template.render(context) + html - return mark_safe(menu) + return mark_safe(html) class ChoiceFieldColumn(tables.Column): From 6e64db46c175ae24f7872031068baaf48f67aa6d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:20:05 -0400 Subject: [PATCH 043/113] Closes #9379: Redirect to virtual chassis view after adding a member device --- docs/release-notes/version-3.2.md | 1 + netbox/templates/dcim/virtualchassis.html | 122 +++++++++++----------- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 7c5e454ef..7497a7374 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,6 +8,7 @@ * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view * [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device ### Bug Fixes diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 4683b775b..1ff9f2e9a 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -15,74 +15,70 @@ {% block content %}
    -
    -
    - Virtual Chassis -
    -
    - - - - - - - - - -
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    -
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    Virtual Chassis
    +
    + + + + + + + + + +
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    -
    - Members -
    -
    - - - - - - - - {% for vc_member in members %} - - - - - - - {% endfor %} -
    DevicePositionMasterPriority
    - {{ vc_member|linkify }} - - {% badge vc_member.vc_position show_empty=True %} - - {% if object.master == vc_member %} - {% checkmark True %} - {% endif %} - - {{ vc_member.vc_priority|placeholder }} -
    -
    - {% if perms.dcim.change_virtualchassis %} - - {% endif %} +
    +
    Members
    +
    + + + + + + + + {% for vc_member in members %} + + + + + + + {% endfor %} +
    DevicePositionMasterPriority
    + {{ vc_member|linkify }} + + {% badge vc_member.vc_position show_empty=True %} + + {% if object.master == vc_member %} + {% checkmark True %} + {% endif %} + + {{ vc_member.vc_priority|placeholder }} +
    - {% plugin_right_page object %} + {% if perms.dcim.change_virtualchassis %} + + {% endif %} +
    + {% plugin_right_page object %}
    -
    - {% plugin_full_width_page object %} -
    +
    + {% plugin_full_width_page object %} +
    {% endblock %} From 147baf69edc88bcd94bd90a585339730c8c88dad Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:49:36 -0400 Subject: [PATCH 044/113] Closes #9347: Include services in global search --- docs/release-notes/version-3.2.md | 1 + netbox/netbox/constants.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 7497a7374..cc558cd20 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,6 +8,7 @@ * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view * [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search * [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device ### Bug Fixes diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e054dc9da..4b080276f 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -16,10 +16,11 @@ from dcim.tables import ( RackReservationTable, SiteTable, VirtualChassisTable, ) from ipam.filtersets import ( - AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet, + AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, ServiceFilterSet, VLANFilterSet, + VRFFilterSet, ) -from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF -from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable +from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF +from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, ServiceTable, VLANTable, VRFTable from tenancy.filtersets import ContactFilterSet, TenantFilterSet from tenancy.models import Contact, Tenant, ContactAssignment from tenancy.tables import ContactTable, TenantTable @@ -191,6 +192,12 @@ IPAM_TYPES = OrderedDict( 'table': ASNTable, 'url': 'ipam:asn_list', }), + ('service', { + 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), + 'filterset': ServiceFilterSet, + 'table': ServiceTable, + 'url': 'ipam:service_list', + }), ) ) From 54850b5da3d23ca8acb8d1037a0be12f84db0766 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:56:14 -0400 Subject: [PATCH 045/113] Clean up imports --- netbox/netbox/constants.py | 129 +++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 69 deletions(-) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 4b080276f..8ca0d98c1 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,33 +1,24 @@ from collections import OrderedDict from typing import Dict -from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet +import circuits.filtersets +import circuits.tables +import dcim.filtersets +import dcim.tables +import ipam.filtersets +import ipam.tables +import tenancy.filtersets +import tenancy.tables +import virtualization.filtersets +import virtualization.tables from circuits.models import Circuit, ProviderNetwork, Provider -from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable -from dcim.filtersets import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet, - PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet, -) from dcim.models import ( Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, ) -from dcim.tables import ( - CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable, - RackReservationTable, SiteTable, VirtualChassisTable, -) -from ipam.filtersets import ( - AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, ServiceFilterSet, VLANFilterSet, - VRFFilterSet, -) from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF -from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, ServiceTable, VLANTable, VRFTable -from tenancy.filtersets import ContactFilterSet, TenantFilterSet from tenancy.models import Contact, Tenant, ContactAssignment -from tenancy.tables import ContactTable, TenantTable from utilities.utils import count_related -from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine -from virtualization.tables import ClusterTable, VirtualMachineTable SEARCH_MAX_RESULTS = 15 @@ -37,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict( 'queryset': Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ), - 'filterset': ProviderFilterSet, - 'table': ProviderTable, + 'filterset': circuits.filtersets.ProviderFilterSet, + 'table': circuits.tables.ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' ), - 'filterset': CircuitFilterSet, - 'table': CircuitTable, + 'filterset': circuits.filtersets.CircuitFilterSet, + 'table': circuits.tables.CircuitTable, 'url': 'circuits:circuit_list', }), ('providernetwork', { 'queryset': ProviderNetwork.objects.prefetch_related('provider'), - 'filterset': ProviderNetworkFilterSet, - 'table': ProviderNetworkTable, + 'filterset': circuits.filtersets.ProviderNetworkFilterSet, + 'table': circuits.tables.ProviderNetworkTable, 'url': 'circuits:providernetwork_list', }), ) @@ -63,22 +54,22 @@ DCIM_TYPES = OrderedDict( ( ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), - 'filterset': SiteFilterSet, - 'table': SiteTable, + 'filterset': dcim.filtersets.SiteFilterSet, + 'table': dcim.tables.SiteTable, 'url': 'dcim:site_list', }), ('rack', { 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( device_count=count_related(Device, 'rack') ), - 'filterset': RackFilterSet, - 'table': RackTable, + 'filterset': dcim.filtersets.RackFilterSet, + 'table': dcim.tables.RackTable, 'url': 'dcim:rack_list', }), ('rackreservation', { 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), - 'filterset': RackReservationFilterSet, - 'table': RackReservationTable, + 'filterset': dcim.filtersets.RackReservationFilterSet, + 'table': dcim.tables.RackReservationTable, 'url': 'dcim:rackreservation_list', }), ('location', { @@ -95,60 +86,60 @@ DCIM_TYPES = OrderedDict( 'rack_count', cumulative=True ).prefetch_related('site'), - 'filterset': LocationFilterSet, - 'table': LocationTable, + 'filterset': dcim.filtersets.LocationFilterSet, + 'table': dcim.tables.LocationTable, 'url': 'dcim:location_list', }), ('devicetype', { 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ), - 'filterset': DeviceTypeFilterSet, - 'table': DeviceTypeTable, + 'filterset': dcim.filtersets.DeviceTypeFilterSet, + 'table': dcim.tables.DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { 'queryset': Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), - 'filterset': DeviceFilterSet, - 'table': DeviceTable, + 'filterset': dcim.filtersets.DeviceFilterSet, + 'table': dcim.tables.DeviceTable, 'url': 'dcim:device_list', }), ('moduletype', { 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Module, 'module_type') ), - 'filterset': ModuleTypeFilterSet, - 'table': ModuleTypeTable, + 'filterset': dcim.filtersets.ModuleTypeFilterSet, + 'table': dcim.tables.ModuleTypeTable, 'url': 'dcim:moduletype_list', }), ('module', { 'queryset': Module.objects.prefetch_related( 'module_type__manufacturer', 'device', 'module_bay', ), - 'filterset': ModuleFilterSet, - 'table': ModuleTable, + 'filterset': dcim.filtersets.ModuleFilterSet, + 'table': dcim.tables.ModuleTable, 'url': 'dcim:module_list', }), ('virtualchassis', { 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( member_count=count_related(Device, 'virtual_chassis') ), - 'filterset': VirtualChassisFilterSet, - 'table': VirtualChassisTable, + 'filterset': dcim.filtersets.VirtualChassisFilterSet, + 'table': dcim.tables.VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), ('cable', { 'queryset': Cable.objects.all(), - 'filterset': CableFilterSet, - 'table': CableTable, + 'filterset': dcim.filtersets.CableFilterSet, + 'table': dcim.tables.CableTable, 'url': 'dcim:cable_list', }), ('powerfeed', { 'queryset': PowerFeed.objects.all(), - 'filterset': PowerFeedFilterSet, - 'table': PowerFeedTable, + 'filterset': dcim.filtersets.PowerFeedFilterSet, + 'table': dcim.tables.PowerFeedTable, 'url': 'dcim:powerfeed_list', }), ) @@ -158,44 +149,44 @@ IPAM_TYPES = OrderedDict( ( ('vrf', { 'queryset': VRF.objects.prefetch_related('tenant'), - 'filterset': VRFFilterSet, - 'table': VRFTable, + 'filterset': ipam.filtersets.VRFFilterSet, + 'table': ipam.tables.VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { 'queryset': Aggregate.objects.prefetch_related('rir'), - 'filterset': AggregateFilterSet, - 'table': AggregateTable, + 'filterset': ipam.filtersets.AggregateFilterSet, + 'table': ipam.tables.AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), - 'filterset': PrefixFilterSet, - 'table': PrefixTable, + 'filterset': ipam.filtersets.PrefixFilterSet, + 'table': ipam.tables.PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), - 'filterset': IPAddressFilterSet, - 'table': IPAddressTable, + 'filterset': ipam.filtersets.IPAddressFilterSet, + 'table': ipam.tables.IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), - 'filterset': VLANFilterSet, - 'table': VLANTable, + 'filterset': ipam.filtersets.VLANFilterSet, + 'table': ipam.tables.VLANTable, 'url': 'ipam:vlan_list', }), ('asn', { 'queryset': ASN.objects.prefetch_related('rir', 'tenant'), - 'filterset': ASNFilterSet, - 'table': ASNTable, + 'filterset': ipam.filtersets.ASNFilterSet, + 'table': ipam.tables.ASNTable, 'url': 'ipam:asn_list', }), ('service', { 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), - 'filterset': ServiceFilterSet, - 'table': ServiceTable, + 'filterset': ipam.filtersets.ServiceFilterSet, + 'table': ipam.tables.ServiceTable, 'url': 'ipam:service_list', }), ) @@ -205,15 +196,15 @@ TENANCY_TYPES = OrderedDict( ( ('tenant', { 'queryset': Tenant.objects.prefetch_related('group'), - 'filterset': TenantFilterSet, - 'table': TenantTable, + 'filterset': tenancy.filtersets.TenantFilterSet, + 'table': tenancy.tables.TenantTable, 'url': 'tenancy:tenant_list', }), ('contact', { 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( assignment_count=count_related(ContactAssignment, 'contact')), - 'filterset': ContactFilterSet, - 'table': ContactTable, + 'filterset': tenancy.filtersets.ContactFilterSet, + 'table': tenancy.tables.ContactTable, 'url': 'tenancy:contact_list', }), ) @@ -226,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict( device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ), - 'filterset': ClusterFilterSet, - 'table': ClusterTable, + 'filterset': virtualization.filtersets.ClusterFilterSet, + 'table': virtualization.tables.ClusterTable, 'url': 'virtualization:cluster_list', }), ('virtualmachine', { 'queryset': VirtualMachine.objects.prefetch_related( 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', ), - 'filterset': VirtualMachineFilterSet, - 'table': VirtualMachineTable, + 'filterset': virtualization.filtersets.VirtualMachineFilterSet, + 'table': virtualization.tables.VirtualMachineTable, 'url': 'virtualization:virtualmachine_list', }), ) From 57ab2214efa70bf7e8447d7ce8c4aae72c1886b6 Mon Sep 17 00:00:00 2001 From: tyler-8 <17618971+tyler-8@users.noreply.github.com> Date: Tue, 24 May 2022 10:57:38 -0400 Subject: [PATCH 046/113] Add optional CSRF_COOKIE_NAME setting, update example config, and docs. --- docs/configuration/optional-settings.md | 8 ++++++++ netbox/netbox/configuration_example.py | 3 +++ netbox/netbox/settings.py | 1 + 3 files changed, 12 insertions(+) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 76fd0a12c..e53a14aa1 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [ --- +## CSRF_COOKIE_NAME + +Default: `csrftoken` + +The name of the cookie to use for the CSRF authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. + +--- + ## CSRF_TRUSTED_ORIGINS Default: `[]` diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index c82749e3f..ad0dcc7c3 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300 # this setting is derived from the installed location. # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' +# The name to use for the csrf token cookie. +CSRF_COOKIE_NAME = 'csrftoken' + # The name to use for the session cookie. SESSION_COOKIE_NAME = 'sessionid' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 59306b8fa..524557db6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -84,6 +84,7 @@ if BASE_PATH: CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) +CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') From fecffc243684526b8123cfe33e9488b188375d3f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 16:00:18 -0400 Subject: [PATCH 047/113] Changelog for #9277 --- docs/configuration/optional-settings.md | 2 +- docs/release-notes/version-3.2.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index e53a14aa1..670cf524b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -70,7 +70,7 @@ CORS_ORIGIN_WHITELIST = [ Default: `csrftoken` -The name of the cookie to use for the CSRF authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. +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. --- diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index cc558cd20..c5b224359 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,6 +8,7 @@ * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view * [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter * [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search * [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device From b10ee60dee226c2bf0bb30bbb5dae63e949d38c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 16:39:05 -0400 Subject: [PATCH 048/113] Changelog & cleanup for #9166 --- docs/release-notes/version-3.3.md | 3 ++- netbox/extras/api/serializers.py | 4 ++-- netbox/extras/filtersets.py | 3 ++- netbox/extras/forms/bulk_import.py | 3 ++- netbox/extras/forms/customfields.py | 4 ++++ netbox/extras/forms/filtersets.py | 2 +- netbox/extras/forms/models.py | 4 ++-- netbox/extras/models/customfields.py | 3 ++- netbox/extras/tables/tables.py | 2 +- netbox/netbox/models/features.py | 2 +- netbox/templates/extras/customfield.html | 8 ++++---- 11 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index d1b6b4cda..cefab428e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -12,6 +12,7 @@ * [#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 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields ### Other Changes @@ -20,7 +21,7 @@ ### REST API Changes * extras.CustomField - * Added `group_name` field + * Added `group_name` and `ui_visibility` fields * 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/extras/api/serializers.py b/netbox/extras/api/serializers.py index 1a26faec1..cb317d6c7 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -90,8 +90,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): model = CustomField fields = [ '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', 'ui_visibility', + 'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] def get_data_type(self, obj): diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index ea74dfc82..b59e28018 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -63,7 +63,8 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField fields = [ - 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description', 'ui_visibility' + 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight', + 'description', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index c0483d36e..95de7a2fe 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -37,7 +37,8 @@ class CustomFieldCSVForm(CSVModelForm): model = CustomField fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', - 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'ui_visibility', + 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'ui_visibility', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index c4496c5f8..4cf8b5e0a 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -51,6 +51,10 @@ class CustomFieldsMixin: if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: self.fields[field_name].disabled = True + if self.fields[field_name].help_text: + self.fields[field_name].help_text += '
    ' + self.fields[field_name].help_text += ' ' \ + 'Field is set to read-only.' # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index cd59a9db1..aaeb45dbe 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -59,7 +59,7 @@ class CustomFieldFilterForm(FilterForm): ui_visibility = forms.ChoiceField( choices=add_blank_choice(CustomFieldVisibilityChoices), required=False, - label=_('UI Visibility'), + label=_('UI visibility'), widget=StaticSelect() ) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 16874c49e..ab423e2fb 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -41,9 +41,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): fieldsets = ( ('Custom Field', ( - 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', 'ui_visibility', + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', )), - ('Behavior', ('filter_logic',)), + ('Behavior', ('filter_logic', 'ui_visibility')), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c48b6895c..c91f96c15 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -140,7 +140,8 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): max_length=50, choices=CustomFieldVisibilityChoices, default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - help_text='Specifies the visibility of custom field in the UI.' + verbose_name='UI visibility', + help_text='Specifies the visibility of custom field in the UI' ) objects = CustomFieldManager() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index d294fd231..540034696 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -34,7 +34,7 @@ class CustomFieldTable(NetBoxTable): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', 'created', 'last_updated', 'ui_visibility', + 'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated', ) 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 76b546192..817da526b 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -125,7 +125,7 @@ class CustomFieldsMixin(models.Model): def get_custom_fields_by_group(self): """ - Return a dictionary of custom field/value mappings organized by group. + Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted. """ grouped_custom_fields = defaultdict(dict) for cf, value in self.get_custom_fields(omit_hidden=True).items(): diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 72dc2e4c3..aca0b5012 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -42,6 +42,10 @@ Weight {{ object.weight }} + + Filter Logic + {{ object.get_filter_logic_display }} + UI Visibility {{ object.get_ui_visibility_display }} @@ -69,10 +73,6 @@ {% endif %} - - Filter Logic - {{ object.get_filter_logic_display }} -
    From 3a0fad3491d4232d8350cb107f87260259977833 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 25 May 2022 16:01:10 -0400 Subject: [PATCH 049/113] Closes #8222: Enable the assignment of a VM to a specific host device within a cluster --- docs/models/virtualization/virtualmachine.md | 2 +- docs/release-notes/version-3.3.md | 5 +++- .../virtualization/virtualmachine.html | 14 +++++---- netbox/utilities/testing/utils.py | 4 +-- netbox/virtualization/api/serializers.py | 17 ++++++----- netbox/virtualization/api/views.py | 2 +- netbox/virtualization/filtersets.py | 12 +++++++- netbox/virtualization/forms/bulk_edit.py | 13 +++++++-- netbox/virtualization/forms/bulk_import.py | 10 +++++-- netbox/virtualization/forms/filtersets.py | 9 ++++-- netbox/virtualization/forms/models.py | 13 +++++++-- .../migrations/0031_virtualmachine_device.py | 20 +++++++++++++ netbox/virtualization/models.py | 13 +++++++++ .../virtualization/tables/virtualmachines.py | 5 +++- netbox/virtualization/tests/test_api.py | 12 ++++++-- .../virtualization/tests/test_filtersets.py | 29 ++++++++++++++----- netbox/virtualization/tests/test_views.py | 23 ++++++++++----- 17 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 netbox/virtualization/migrations/0031_virtualmachine_device.py diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index de9b5f214..b903ea131 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,6 +1,6 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster. +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster, and may optionally be assigned to a particular host device within that cluster. Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index cefab428e..6f07ea87d 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#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 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results @@ -26,4 +27,6 @@ * 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 * virtualization.Cluster - * Add required `status` field (default value: `active`) + * Added required `status` field (default value: `active`) +* virtualization.VirtualMachine + * Added `device` field diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0dec4968c..ac8409e09 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -78,9 +78,7 @@
    -
    - Cluster -
    +
    Cluster
    @@ -96,13 +94,17 @@ + + + +
    Cluster Type {{ object.cluster.type }}
    Device + {{ object.device|linkify|placeholder }} +
    -
    - Resources -
    +
    Resources
    diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 466b5e22b..6157d342d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -34,7 +34,7 @@ def post_data(data): return ret -def create_test_device(name): +def create_test_device(name, **attrs): """ Convenience method for creating a Device (e.g. for component testing). """ @@ -42,7 +42,7 @@ def create_test_device(name): manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs) return device diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index e127bd5fa..d12d9affd 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,7 +1,9 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import ( + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, +) from dcim.choices import InterfaceModeChoices from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer from ipam.models import VLAN @@ -68,6 +70,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer): status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = NestedSiteSerializer(read_only=True) cluster = NestedClusterSerializer() + device = NestedDeviceSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) @@ -78,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -90,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 665114881..d86241b4f 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'cluster__site', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 63e3557a3..3e1d50da4 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet @@ -150,6 +150,16 @@ class VirtualMachineFilterSet( to_field_name='name', label='Cluster', ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device', + ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='cluster__site__region', diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index e7369f53a..67126d6c7 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import VLAN, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant @@ -110,6 +110,13 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): queryset=Cluster.objects.all(), required=False ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' + } + ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True @@ -146,11 +153,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('cluster', 'status', 'role', 'tenant', 'platform')), + (None, ('cluster', 'device', 'status', 'role', 'tenant', 'platform')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index ef688367e..41f9b3773 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,5 +1,5 @@ from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Platform, Site +from dcim.models import Device, DeviceRole, Platform, Site from ipam.models import VRF from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant @@ -76,6 +76,12 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): to_field_name='name', help_text='Assigned cluster' ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned device within cluster' + ) role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True @@ -100,7 +106,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 753f509f7..b3da87f7a 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm from ipam.models import VRF from netbox.forms import NetBoxModelFilterSetForm @@ -87,7 +87,7 @@ class VirtualMachineFilterForm( model = VirtualMachine fieldsets = ( (None, ('q', 'tag')), - ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')), + ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -110,6 +110,11 @@ class VirtualMachineFilterForm( required=False, label=_('Cluster') ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index a94cc3920..dba12d64d 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -179,6 +179,13 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): 'group_id': '$cluster_group' } ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' + } + ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, @@ -197,7 +204,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), + ('Cluster', ('cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), @@ -207,8 +214,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', + 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " diff --git a/netbox/virtualization/migrations/0031_virtualmachine_device.py b/netbox/virtualization/migrations/0031_virtualmachine_device.py new file mode 100644 index 000000000..407d60e79 --- /dev/null +++ b/netbox/virtualization/migrations/0031_virtualmachine_device.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.4 on 2022-05-25 19:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ('virtualization', '0030_cluster_status'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index afc450ddd..51dbc9f43 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -200,6 +200,13 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): on_delete=models.PROTECT, related_name='virtual_machines' ) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -316,6 +323,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def clean(self): super().clean() + # Validate assigned cluster 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}).' + }) + # Validate primary IP addresses interfaces = self.interfaces.all() for field in ['primary_ip4', 'primary_ip6']: diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 89dbdf901..80eb0b37f 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -33,6 +33,9 @@ class VirtualMachineTable(NetBoxTable): cluster = tables.Column( linkify=True ) + device = tables.Column( + linkify=True + ) role = columns.ColoredLabelColumn() tenant = TenantColumn() comments = columns.MarkdownColumn() @@ -56,7 +59,7 @@ class VirtualMachineTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'pk', 'id', 'name', 'status', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 4d559dc49..887781e01 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -3,7 +3,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from ipam.models import VLAN, VRF -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -152,8 +152,15 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): ) Cluster.objects.bulk_create(clusters) + device1 = create_test_device('device1') + device1.cluster = clusters[0] + device1.save() + device2 = create_test_device('device2') + device2.cluster = clusters[1] + device2.save() + virtual_machines = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}), + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=device1, local_context_data={'A': 1}), VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}), VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}), ) @@ -163,6 +170,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): { 'name': 'Virtual Machine 4', 'cluster': clusters[1].pk, + 'device': device2.pk, }, { 'name': 'Virtual Machine 5', diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 8b4e79bed..3fd43d0c1 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,9 +1,9 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress, VRF from tenancy.models import Tenant, TenantGroup -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * from virtualization.filtersets import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -225,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -252,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ) DeviceRole.objects.bulk_create(roles) + devices = ( + create_test_device('device1', cluster=clusters[0]), + create_test_device('device2', cluster=clusters[1]), + create_test_device('device3', cluster=clusters[2]), + ) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -268,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), + VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), + VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) VirtualMachine.objects.bulk_create(vms) @@ -331,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cluster': [clusters[0].name, clusters[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index df90bfc37..4b1d64de5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -5,7 +5,7 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN, VRF -from utilities.testing import ViewTestCases, create_tags +from utilities.testing import ViewTestCases, create_tags, create_test_device from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -176,16 +176,22 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Cluster.objects.bulk_create(clusters) + devices = ( + create_test_device('device1', cluster=clusters[0]), + create_test_device('device2', cluster=clusters[1]), + ) + VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'cluster': clusters[1].pk, + 'device': devices[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'name': 'Virtual Machine X', @@ -202,14 +208,15 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,cluster", - "Virtual Machine 4,active,Cluster 1", - "Virtual Machine 5,active,Cluster 1", - "Virtual Machine 6,active,Cluster 1", + "name,status,cluster,device", + "Virtual Machine 4,active,Cluster 1,device1", + "Virtual Machine 5,active,Cluster 1,device1", + "Virtual Machine 6,active,Cluster 1,", ) cls.bulk_edit_data = { 'cluster': clusters[1].pk, + 'device': devices[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'status': VirtualMachineStatusChoices.STATUS_STAGED, From 8a08e11edce815d0fb3a9c6570d1d9713fbd3e9d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 26 May 2022 14:59:49 -0400 Subject: [PATCH 050/113] Closes #5303: A virtual machine may be assigned to a site and/or cluster --- docs/models/virtualization/virtualmachine.md | 2 +- docs/release-notes/version-3.3.md | 3 ++ .../virtualization/virtualmachine.html | 8 +++- netbox/utilities/testing/utils.py | 5 ++- netbox/virtualization/api/serializers.py | 4 +- netbox/virtualization/api/views.py | 2 +- netbox/virtualization/filtersets.py | 11 +++-- netbox/virtualization/forms/bulk_edit.py | 19 ++++++--- netbox/virtualization/forms/bulk_import.py | 10 ++++- netbox/virtualization/forms/models.py | 13 ++++-- .../migrations/0031_virtualmachine_device.py | 20 ---------- .../0031_virtualmachine_site_device.py | 28 +++++++++++++ .../0032_virtualmachine_update_sites.py | 27 +++++++++++++ netbox/virtualization/models.py | 33 ++++++++++++--- .../virtualization/tables/virtualmachines.py | 9 +++-- netbox/virtualization/tests/test_api.py | 35 ++++++++++------ .../virtualization/tests/test_filtersets.py | 6 +-- netbox/virtualization/tests/test_models.py | 40 ++++++++++++++++--- netbox/virtualization/tests/test_views.py | 34 ++++++++++------ 19 files changed, 223 insertions(+), 86 deletions(-) delete mode 100644 netbox/virtualization/migrations/0031_virtualmachine_device.py create mode 100644 netbox/virtualization/migrations/0031_virtualmachine_site_device.py create mode 100644 netbox/virtualization/migrations/0032_virtualmachine_update_sites.py diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index b903ea131..4ddffb99a 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,6 +1,6 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster, and may optionally be assigned to a particular host device within that cluster. +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster. Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6f07ea87d..63fd9731f 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#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 @@ -30,3 +31,5 @@ * Added required `status` field (default value: `active`) * virtualization.VirtualMachine * Added `device` field + * 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. diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ac8409e09..2831a452a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -81,13 +81,19 @@
    Cluster
    + + + + diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 6157d342d..52ccd002d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -34,11 +34,12 @@ def post_data(data): return ret -def create_test_device(name, **attrs): +def create_test_device(name, site=None, **attrs): """ Convenience method for creating a Device (e.g. for component testing). """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + if site is None: + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index d12d9affd..bd01b5533 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -68,8 +68,8 @@ class ClusterSerializer(NetBoxModelSerializer): class VirtualMachineSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) - site = NestedSiteSerializer(read_only=True) - cluster = NestedClusterSerializer() + site = NestedSiteSerializer(required=False, allow_null=True) + cluster = NestedClusterSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index d86241b4f..d2a90ae34 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'cluster__site', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 3e1d50da4..00d3e2313 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -162,37 +162,36 @@ class VirtualMachineFilterSet( ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', label='Site group (ID)', ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', to_field_name='slug', label='Site group (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 67126d6c7..88dee3978 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -106,9 +106,16 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect(), ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } ) device = DynamicModelChoiceField( queryset=Device.objects.all(), @@ -153,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('cluster', 'device', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) @@ -236,8 +243,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): # See 5643 if 'pk' in self.initial: site = None - interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( - 'virtual_machine__cluster__site' + interfaces = VMInterface.objects.filter( + pk__in=self.initial['pk'] + ).prefetch_related( + 'virtual_machine__site' ) # Check interface sites. First interface should set site, further interfaces will either continue the diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 41f9b3773..2d7ee52e2 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -71,9 +71,16 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): choices=VirtualMachineStatusChoices, help_text='Operational status' ) + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned site' + ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', + required=False, help_text='Assigned cluster' ) device = CSVModelChoiceField( @@ -106,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'comments', ) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index dba12d64d..cfafd7e39 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -165,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm): class VirtualMachineForm(TenancyForm, NetBoxModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -176,7 +179,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), query_params={ - 'group_id': '$cluster_group' + 'site_id': '$site', + 'group_id': '$cluster_group', } ) device = DynamicModelChoiceField( @@ -204,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster', 'device')), + ('Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), @@ -214,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " diff --git a/netbox/virtualization/migrations/0031_virtualmachine_device.py b/netbox/virtualization/migrations/0031_virtualmachine_device.py deleted file mode 100644 index 407d60e79..000000000 --- a/netbox/virtualization/migrations/0031_virtualmachine_device.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-25 19:30 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0153_created_datetimefield'), - ('virtualization', '0030_cluster_status'), - ] - - operations = [ - migrations.AddField( - model_name='virtualmachine', - name='device', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), - ), - ] diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py new file mode 100644 index 000000000..85ea24455 --- /dev/null +++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ('virtualization', '0030_cluster_status'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'), + ), + migrations.AddField( + model_name='virtualmachine', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), + ), + migrations.AlterField( + model_name='virtualmachine', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'), + ), + ] diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py new file mode 100644 index 000000000..e9c52bfde --- /dev/null +++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def update_virtualmachines_site(apps, schema_editor): + """ + Automatically set the site for all virtual machines. + """ + VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') + + virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False) + for vm in virtual_machines: + vm.site = vm.cluster.site + VirtualMachine.objects.bulk_update(virtual_machines, ['site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0031_virtualmachine_site_device'), + ] + + operations = [ + migrations.RunPython( + code=update_virtualmachines_site, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 51dbc9f43..02560a962 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -195,10 +195,19 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) cluster = models.ForeignKey( to='virtualization.Cluster', on_delete=models.PROTECT, - related_name='virtual_machines' + related_name='virtual_machines', + blank=True, + null=True ) device = models.ForeignKey( to='dcim.Device', @@ -291,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): objects = ConfigContextModelQuerySet.as_manager() clone_fields = [ - 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', + 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', ] class Meta: @@ -323,6 +332,22 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def clean(self): super().clean() + # Must be assigned to a site and/or cluster + if not self.site and not self.cluster: + raise ValidationError({ + 'cluster': f'A virtual machine must be assigned to a site and/or cluster.' + }) + + # Validate site for cluster & device + if self.cluster and self.cluster.site != self.site: + raise ValidationError({ + 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).' + }) + if self.device and self.device.site != self.site: + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).' + }) + # Validate assigned cluster device if self.device and self.device not in self.cluster.devices.all(): raise ValidationError({ @@ -357,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): else: return None - @property - def site(self): - return self.cluster.site - # # Interfaces diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 80eb0b37f..0fe2571b1 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -30,6 +30,9 @@ class VirtualMachineTable(NetBoxTable): linkify=True ) status = columns.ChoiceFieldColumn() + site = tables.Column( + linkify=True + ) cluster = tables.Column( linkify=True ) @@ -59,11 +62,11 @@ class VirtualMachineTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', + 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', ) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 887781e01..b2ae68860 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,6 +2,7 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices +from dcim.models import Site from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.choices import * @@ -146,39 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + clusters = ( - Cluster(name='Cluster 1', type=clustertype, group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, group=clustergroup), + Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup), + Cluster(name='Cluster 3', type=clustertype), ) Cluster.objects.bulk_create(clusters) - device1 = create_test_device('device1') - device1.cluster = clusters[0] - device1.save() - device2 = create_test_device('device2') - device2.cluster = clusters[1] - device2.save() + device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) + device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) virtual_machines = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=device1, local_context_data={'A': 1}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}), ) VirtualMachine.objects.bulk_create(virtual_machines) cls.create_data = [ { 'name': 'Virtual Machine 4', + 'site': sites[1].pk, 'cluster': clusters[1].pk, 'device': device2.pk, }, { 'name': 'Virtual Machine 5', + 'site': sites[1].pk, 'cluster': clusters[1].pk, }, { 'name': 'Virtual Machine 6', - 'cluster': clusters[1].pk, + 'site': sites[1].pk, + }, + { + 'name': 'Virtual Machine 7', + 'cluster': clusters[2].pk, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 3fd43d0c1..d3ff12887 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -274,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), + VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), + VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) VirtualMachine.objects.bulk_create(vms) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index 3b4d73a30..df5816efa 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -1,21 +1,19 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from dcim.models import Site from virtualization.models import * from tenancy.models import Tenant class VirtualMachineTestCase(TestCase): - def setUp(self): - - cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1') - self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type) - def test_vm_duplicate_name_per_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) vm1 = VirtualMachine( - cluster=self.cluster, + cluster=cluster, name='Test VM 1' ) vm1.save() @@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase): # Two VMs assigned to the same Cluster and different Tenants should pass validation vm2.full_clean() vm2.save() + + def test_vm_mismatched_site_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + clusters = ( + Cluster(name='Cluster 1', type=cluster_type, site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, site=None), + ) + Cluster.objects.bulk_create(clusters) + + # VM with site only should pass + VirtualMachine(name='vm1', site=sites[0]).full_clean() + + # VM with non-site cluster only should pass + VirtualMachine(name='vm1', cluster=clusters[2]).full_clean() + + # VM with mismatched site & cluster should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean() + + # VM with cluster site but no direct site should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 4b1d64de5..01d4394f3 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -168,23 +168,29 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Platform.objects.bulk_create(platforms) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=clustertype), - Cluster(name='Cluster 2', type=clustertype), + Cluster(name='Cluster 1', type=clustertype, site=sites[0]), + Cluster(name='Cluster 2', type=clustertype, site=sites[1]), ) Cluster.objects.bulk_create(clusters) devices = ( - create_test_device('device1', cluster=clusters[0]), - create_test_device('device2', cluster=clusters[1]), + create_test_device('device1', site=sites[0], cluster=clusters[0]), + create_test_device('device2', site=sites[1], cluster=clusters[1]), ) VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -192,6 +198,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'cluster': clusters[1].pk, 'device': devices[1].pk, + 'site': sites[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'name': 'Virtual Machine X', @@ -208,13 +215,14 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,cluster,device", - "Virtual Machine 4,active,Cluster 1,device1", - "Virtual Machine 5,active,Cluster 1,device1", - "Virtual Machine 6,active,Cluster 1,", + "name,status,site,cluster,device", + "Virtual Machine 4,active,Site 1,Cluster 1,device1", + "Virtual Machine 5,active,Site 1,Cluster 1,device1", + "Virtual Machine 6,active,Site 1,Cluster 1,", ) cls.bulk_edit_data = { + 'site': sites[1].pk, 'cluster': clusters[1].pk, 'device': devices[1].pk, 'tenant': None, @@ -252,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site) virtualmachines = ( - VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole), - VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole), ) VirtualMachine.objects.bulk_create(virtualmachines) From 643f5bc8cc191dc1c788b7063d64fc753477a175 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 27 May 2022 20:41:50 +0200 Subject: [PATCH 051/113] Make sure initial data is passed as array for DynamicModelChoiceFields --- netbox/utilities/forms/fields/dynamic.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index f83fc6a7c..dc3bab9fc 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -88,7 +88,12 @@ class DynamicModelChoiceMixin: # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() + if data: + # When the field is multiple choice pass the data as a list if it's not already + if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list: + data = [data] + field_name = getattr(self, 'to_field_name') or 'pk' filter = self.filter(field_name=field_name) try: From 4e418850a23ef890db1fbdd87d21556954e111ae Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 28 May 2022 11:29:18 +0200 Subject: [PATCH 052/113] Iterate base classes when searching for ScriptVariables --- netbox/extras/scripts.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4332d72f7..29fab5be8 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -306,9 +306,16 @@ class BaseScript: @classmethod def _get_vars(cls): vars = {} - for name, attr in cls.__dict__.items(): - if name not in vars and issubclass(attr.__class__, ScriptVariable): - vars[name] = attr + + # Iterate all base classes looking for ScriptVariables + for base_class in inspect.getmro(cls): + # When object is reached there's no reason to continue + if base_class is object: + break + + for name, attr in base_class.__dict__.items(): + if name not in vars and issubclass(attr.__class__, ScriptVariable): + vars[name] = attr # Order variables according to field_order field_order = getattr(cls.Meta, 'field_order', None) From 01c96b817ca5016ef36fa6e12c4ac26973282b85 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 09:14:23 -0400 Subject: [PATCH 053/113] Changelog for #9420, #9430 --- docs/release-notes/version-3.2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index c5b224359..3a46e060f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -17,6 +17,8 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance +* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields --- From fa83afe215cedaddf3d6e2f1c5ac478330c26dbd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 11:37:30 -0400 Subject: [PATCH 054/113] Fixes #9425: Fix bulk import for object and multi-object custom fields --- docs/release-notes/version-3.2.md | 1 + netbox/extras/forms/bulk_import.py | 11 +++++++++-- netbox/extras/tests/test_views.py | 9 +++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3a46e060f..8fdfafdb7 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed * [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance +* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index fa6d8af55..878b83c7c 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm): choices=CustomFieldTypeChoices, help_text='Field data type (e.g. text, integer, etc.)' ) + object_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False, + help_text="Object type (for object or multi-object fields)" + ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, @@ -36,8 +42,9 @@ 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', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', + 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', + 'validation_regex', ) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ea3a952d6..1cfc4b3cc 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -39,10 +39,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', - 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', - 'field5,Field 5,integer,dcim.site,100,exact,,1,100,', - 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', + 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', + 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3}', + 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,', + 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,', + 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,', ) cls.bulk_edit_data = { From d18d9c557578e29012e196633e31900c6aa1be0e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 12:01:33 -0400 Subject: [PATCH 055/113] Closes #9451: Add export_raw argument for TemplateColumn --- docs/plugins/development/tables.md | 3 ++- docs/release-notes/version-3.2.md | 1 + netbox/netbox/tables/columns.py | 13 +++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index 77e258def..6dccb4ee2 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl ::: netbox.tables.TemplateColumn selection: - members: false + members: + - __init__ diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 8fdfafdb7..b4efda4dc 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -11,6 +11,7 @@ * [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter * [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search * [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device +* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn ### Bug Fixes diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 0c26e541e..e82e8a1ea 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -90,6 +90,15 @@ class TemplateColumn(tables.TemplateColumn): """ PLACEHOLDER = mark_safe('—') + def __init__(self, export_raw=False, **kwargs): + """ + Args: + export_raw: If true, data export returns the raw field value rather than the rendered template. (Default: + False) + """ + super().__init__(**kwargs) + self.export_raw = export_raw + def render(self, *args, **kwargs): ret = super().render(*args, **kwargs) if not ret.strip(): @@ -97,6 +106,10 @@ class TemplateColumn(tables.TemplateColumn): return ret def value(self, **kwargs): + if self.export_raw: + # Skip template rendering and export raw value + return kwargs.get('value') + ret = super().value(**kwargs) if ret == self.PLACEHOLDER: return '' From 127588f9eeb4939e477f7da239aa31b6ceb05ac3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 12:23:22 -0400 Subject: [PATCH 056/113] Fixes #9407: Clean up display of prefixes values when exporting prefixes list --- docs/release-notes/version-3.2.md | 1 + netbox/ipam/tables/ip.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b4efda4dc..e4594cd6c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list * [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 475ad787e..558631585 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(NetBoxTable): - prefix = tables.TemplateColumn( + prefix = columns.TemplateColumn( template_code=PREFIX_LINK, + export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) prefix_flat = tables.TemplateColumn( From e66cb61e0bf322c5fd52643c8eb9d091ba007b86 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 13:26:25 -0400 Subject: [PATCH 057/113] Fixes #9402: Fix custom field population when creating a virtual chassis --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/object_create.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e4594cd6c..b38b95ba8 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis * [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list * [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index e3e9c1179..8c9ddab19 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm): ] def clean(self): + super().clean() + if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: raise forms.ValidationError({ 'initial_position': "A position must be specified for the first VC member." From bd4dfa221451424dab5f85917c8829ad20195d6c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 13:37:14 -0400 Subject: [PATCH 058/113] Fixes #9291: Improve data validation for MultiObjectVar script fields --- docs/release-notes/version-3.2.md | 1 + netbox/utilities/forms/fields/dynamic.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b38b95ba8..90c2143d2 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -16,6 +16,7 @@ ### Bug Fixes * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters +* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed * [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index dc3bab9fc..68e71610c 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -135,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip widget = widgets.APISelectMultiple def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ + value = value or [] + + # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] return [None, *value] + return super().clean(value) From c181c9bda813a556d0f5ba69489985b552de5f84 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 15:08:33 -0400 Subject: [PATCH 059/113] Release v3.2.4 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.2.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index df5ac6e81..a9af9c653 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.3 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 422b87f52..1fff99f1d 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.3 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 90c2143d2..fa533a475 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,6 @@ # NetBox v3.2 -## v3.2.4 (FUTURE) +## v3.2.4 (2022-05-31) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 524557db6..bd351a0a1 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.2.4-dev' +VERSION = '3.2.4' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 0a15fcf20..293a33542 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,10 +18,10 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.14 -mkdocstrings[python-legacy]==0.18.1 +mkdocs-material==8.2.16 +mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.1.0 +Pillow==9.1.1 psycopg2-binary==2.9.3 PyYAML==6.0 sentry-sdk==1.5.12 From 06112d7be4d24d5dcff8ac563fe4f1c0a5016690 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 15:31:22 -0400 Subject: [PATCH 060/113] PRVB --- docs/release-notes/version-3.2.md | 5 ++++- netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index fa533a475..ea5e580b8 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,9 @@ # NetBox v3.2 +## v3.2.5 (FUTURE) + +--- + ## v3.2.4 (2022-05-31) ### Enhancements @@ -25,7 +29,6 @@ * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields - --- ## v3.2.3 (2022-05-12) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bd351a0a1..16c2b8b6e 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.2.4' +VERSION = '3.2.5-dev' # Hostname HOSTNAME = platform.node() From e752025ac48c8018bd08254c502c71b5713f8aaa Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Fri, 3 Jun 2022 13:03:58 +0200 Subject: [PATCH 061/113] Clear webhook queue on script failure --- netbox/extras/management/commands/runscript.py | 5 +++-- netbox/extras/scripts.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 12188619f..2296ce1ff 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices from extras.context_managers import change_logging from extras.models import JobResult from extras.scripts import get_script +from extras.signals import clear_webhooks from utilities.exceptions import AbortTransaction from utilities.utils import NetBoxFakeRequest @@ -49,7 +50,7 @@ class Command(BaseCommand): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -58,7 +59,7 @@ class Command(BaseCommand): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4332d72f7..e36cbb5a8 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -17,6 +17,7 @@ from django.utils.functional import classproperty from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices +from extras.signals import clear_webhooks from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from utilities.exceptions import AbortTransaction @@ -458,7 +459,7 @@ def run_script(data, request, commit=True, *args, **kwargs): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -467,7 +468,7 @@ def run_script(data, request, commit=True, *args, **kwargs): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() From 28daad10002eaec1916f16c1a40c4e61b333de2d Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sun, 5 Jun 2022 10:31:21 +0200 Subject: [PATCH 062/113] Make the Service and ServiceTemplate tables sortable by ports --- netbox/ipam/tables/services.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8c81a28c2..58d0a9aff 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable): linkify=True ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:servicetemplate_list' @@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable): order_by=('device', 'virtual_machine') ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:service_list' From 75f286f55a0e993c1f5b2789f5e30946d1784b63 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 6 Jun 2022 16:28:33 +0200 Subject: [PATCH 063/113] List services listening on all IPs in IPAddressView --- netbox/ipam/views.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 84f6db6d5..a01f2d052 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -7,12 +7,12 @@ from django.urls import reverse from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet -from dcim.models import Interface, Site +from dcim.models import Interface, Site, Device from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VMInterface +from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables from .constants import * from .models import * @@ -676,7 +676,19 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) - services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance) + # Find services belonging to the IP + service_filter = Q(ipaddresses=instance) + + # Find services listening on all IPs on the assigned device/vm + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object + + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + + services = Service.objects.restrict(request.user, 'view').filter(service_filter) return { 'parent_prefixes_table': parent_prefixes_table, From fe7825ddb7f7b17cf55731a46a4709da5f7b16a1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 08:51:53 -0400 Subject: [PATCH 064/113] Changelog for #9480, #9484 --- docs/release-notes/version-3.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ea5e580b8..baab085ce 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,11 @@ ## v3.2.5 (FUTURE) +### Bug Fixes + +* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers +* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view + --- ## v3.2.4 (2022-05-31) From b8bbb7e638bbe06ccff91ac37d79888435bbbb2a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 09:59:59 -0400 Subject: [PATCH 065/113] Add warning against bumping stale issues --- .github/workflows/stale.yml | 5 ++++- CONTRIBUTING.md | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7390ec1df..57666417a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,7 +27,10 @@ jobs: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + issues may receive direct feedback. **Do not** attempt to circumvent this + process by "bumping" the issue; doing so will result in its immediate closure + and you may be barred from participating in any future discussions. Please see + our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). stale-pr-label: 'pending closure' stale-pr-message: > This PR has been automatically marked as stale because it has not had diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c01adf4c9..1b4733cbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,9 +160,9 @@ to aid in issue management. It is natural that some new issues get more attention than others. The stale bot helps bring renewed attention to potentially valuable issues that may have -been overlooked. **Do not** comment on an issue that has been marked stale in -an effort to circumvent the bot: Doing so will not remove the stale label. -(Stale labels can be removed only by maintainers.) +been overlooked. **Do not** comment on a stale issue merely to "bump" it in an +effort to circumvent the bot: This will result in the immediate closure of the +issue, and you may be barred from participating in future discussions. ## Maintainer Guidance From 2e7d5e609807f0ab943e7f0228679478db9fa1d8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 10:06:19 -0400 Subject: [PATCH 066/113] Fixes #9486: Fix redirect URL when adding device components from the module view --- docs/release-notes/version-3.2.md | 1 + netbox/templates/dcim/module.html | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index baab085ce..3ffc33e71 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,6 +6,7 @@ * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view +* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view --- diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 130cd046f..f2dac38f2 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -18,25 +18,25 @@ From 68a7cd132377f1be1d5a87f37754eb3c568fc873 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 11:00:14 -0400 Subject: [PATCH 067/113] Closes #8882: Support filtering IP addresses by multiple parent prefixes --- docs/release-notes/version-3.2.md | 4 ++++ netbox/ipam/filtersets.py | 16 +++++++++------- netbox/ipam/tests/test_filtersets.py | 6 ++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3ffc33e71..4534c35c1 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,10 @@ ## v3.2.5 (FUTURE) +### Enhancements + +* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes + ### Bug Fixes * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index a445022ca..d9cf6eefc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -464,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): field_name='address', lookup_expr='family' ) - parent = django_filters.CharFilter( + parent = MultiValueCharFilter( method='search_by_parent', label='Parent prefix', ) @@ -571,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) def search_by_parent(self, queryset, name, value): - value = value.strip() if not value: return queryset - try: - query = str(netaddr.IPNetwork(value.strip()).cidr) - return queryset.filter(address__net_host_contained=query) - except (AddrFormatError, ValueError): - return queryset.none() + q = Q() + for prefix in value: + try: + query = str(netaddr.IPNetwork(prefix.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + return queryset.filter(q) def filter_address(self, queryset, name, value): try: diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 198f9d62d..d98fe889e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - params = {'parent': '10.0.0.0/24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'parent': '2001:db8::/64'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'parent': ['10.0.0.0/30', '2001:db8::/126']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_filter_address(self): # Check IPv4 and IPv6, with and without a mask From 94784d986b1fda97c0adcf5c1dbd70090ff80d33 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 11:12:40 -0400 Subject: [PATCH 068/113] Closes #8893: Include count of IP ranges under tenant view --- docs/release-notes/version-3.2.md | 1 + netbox/templates/tenancy/tenant.html | 4 ++++ netbox/tenancy/views.py | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 4534c35c1..3d1f86bec 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -5,6 +5,7 @@ ### Enhancements * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes +* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view ### Bug Fixes diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e4c1db006..52c13e1aa 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -77,6 +77,10 @@

    {{ stats.prefix_count }}

    Prefixes

    +
    +

    {{ stats.iprange_count }}

    +

    IP Ranges

    +

    {{ stats.ipaddress_count }}

    IP addresses

    diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 58ad98e8f..f6f95b123 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster @@ -104,8 +104,9 @@ class TenantView(generic.ObjectView): 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), From d28d025a9719316725581b154e59dc368c081420 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 10:20:44 -0400 Subject: [PATCH 069/113] Fixes #9495: Correct link to contacts in contact groups table column --- docs/release-notes/version-3.2.md | 1 + netbox/tenancy/tables/contacts.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3d1f86bec..339081902 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,7 @@ * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view +* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column --- diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 17abc5a5b..234dc2ad7 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable): ) contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', - url_params={'role_id': 'pk'}, + url_params={'group_id': 'pk'}, verbose_name='Contacts' ) tags = columns.TagColumn( From e59a9e5b03e2184181af6919510406829593b36f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 11:48:32 -0400 Subject: [PATCH 070/113] Closes #9434: Enabled django-rich test runner for more user-friendly output --- base_requirements.txt | 6 +++++- docs/release-notes/version-3.3.md | 1 + netbox/netbox/settings.py | 2 ++ requirements.txt | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/base_requirements.txt b/base_requirements.txt index 6bb537a6a..10e8af3ba 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -30,10 +30,14 @@ django-pglocks # https://github.com/korfuri/django-prometheus django-prometheus -# Django chaching backend using Redis +# Django caching backend using Redis # https://github.com/jazzband/django-redis django-redis +# Django extensions for Rich (terminal text rendering) +# https://github.com/adamchainz/django-rich +django-rich + # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq django-rq diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 63fd9731f..514a92e88 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ ### 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 +* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output ### REST API Changes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index fd3730e2c..f9f4728a9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -423,6 +423,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +TEST_RUNNER = "django_rich.test.RichRunner" + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( diff --git a/requirements.txt b/requirements.txt index 293a33542..d6ea22e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 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 From 56adc0cd1e4119397e750d43544259713781a524 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 12:45:48 -0400 Subject: [PATCH 071/113] Fix typo --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d6ea22e7d..1def8e23e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 -django-rich-1.4.0 +django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 From 03c83b536192a9f8770ab8356e1292554af91531 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 10 Jun 2022 23:13:49 +0200 Subject: [PATCH 072/113] Add configuration option JINJA2_FILTERS --- docs/configuration/optional-settings.md | 14 ++++++++++++++ netbox/netbox/settings.py | 1 + netbox/utilities/utils.py | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 670cf524b..0a7c7b6e0 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -255,6 +255,20 @@ HTTP_PROXIES = { --- +## 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 +JINJA2_FILTERS = { + 'uppercase': uppercase, +} +``` + +--- + ## INTERNAL_IPS Default: `('127.0.0.1', '::1')` diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 16c2b8b6e..f30dea4d7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -96,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) +JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 7b37c0b70..bc6d928ed 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -14,6 +14,7 @@ from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices from extras.plugins import PluginConfig from extras.utils import is_taggable +from netbox.config import get_config from utilities.constants import HTTP_REQUEST_META_SAFE_COPY @@ -257,7 +258,9 @@ def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. """ - return SandboxedEnvironment().from_string(source=template_code).render(**context) + environment = SandboxedEnvironment() + environment.filters.update(get_config().JINJA2_FILTERS) + return environment.from_string(source=template_code).render(**context) def prepare_cloned_fields(instance): From 5639d1a832128590c92db0440fad34e1fcab9152 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Mon, 13 Jun 2022 07:56:31 +0200 Subject: [PATCH 073/113] Add distinct to Site search to prevent duplicates when search matches ASN --- netbox/dcim/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d57d0a59b..f052a8be9 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass - return queryset.filter(qs_filter) + return queryset.filter(qs_filter).distinct() class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): From 423d5e57ed27694f588bfd73bdcd11864782fc8f Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 13 Jun 2022 20:45:08 +0200 Subject: [PATCH 074/113] Implement a custom paginator for DeviceViewSet Running count on the annotated query for loading config_context is slow. The custom paginator removes the annotation before getting the count. --- netbox/dcim/api/views.py | 2 ++ netbox/netbox/api/pagination.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e99ef333a..c4c25f654 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata +from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.viewsets import NetBoxModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model @@ -392,6 +393,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) filterset_class = filtersets.DeviceFilterSet + pagination_class = StripCountAnnotationsPaginator def get_serializer_class(self): """ diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index d89e32124..5ecade264 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): if isinstance(queryset, QuerySet): - self.count = queryset.count() + self.count = self.get_queryset_count(queryset) else: # We're dealing with an iterable, not a QuerySet self.count = len(queryset) @@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return self.default_limit + def get_queryset_count(self, queryset): + return queryset.count() + def get_next_link(self): # Pagination has been disabled @@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return None return super().get_previous_link() + + +class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination): + """ + Strips the annotations on the queryset before getting the count + to optimize pagination of complex queries. + """ + def get_queryset_count(self, queryset): + # Clone the queryset to avoid messing up the actual query + cloned_queryset = queryset.all() + cloned_queryset.query.annotations.clear() + + return cloned_queryset.count() From c56c5bff257b379111feb6a703e6f2c94b5ba1b2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Jun 2022 19:05:16 -0400 Subject: [PATCH 075/113] Changelog for #9501, #9512 --- docs/configuration/optional-settings.md | 5 ++++- docs/release-notes/version-3.2.md | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 0a7c7b6e0..3b1c848a7 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -259,9 +259,12 @@ HTTP_PROXIES = { 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: +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, } diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 339081902..93ca4a840 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,6 +6,7 @@ * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view +* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters ### Bug Fixes @@ -13,6 +14,7 @@ * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column +* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN --- From 844cf22f6bbbfabd0e41230781654162722c4278 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Jun 2022 19:14:29 -0400 Subject: [PATCH 076/113] Fixes #9524: Correct order of VLAN fields under VM interface creation form --- docs/release-notes/version-3.2.md | 1 + netbox/virtualization/forms/models.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 93ca4a840..05d798e54 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -15,6 +15,7 @@ * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN +* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form --- diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 314b0bddf..d2ebe5345 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -307,7 +307,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'virtual_machine': forms.HiddenInput(), From 59f83bd3577da2929ac8491a8a517fb4d7a8d5ef Mon Sep 17 00:00:00 2001 From: Kim Johansson Date: Wed, 15 Jun 2022 22:33:21 +0200 Subject: [PATCH 077/113] Replace None in templates with placeholder filter To be consistent, all uses of — or None is replaced with the placeholder filter. Fixes #9537 --- .../circuits/circuit_terminations_swap.html | 4 ++-- .../templates/circuits/inc/circuit_termination.html | 2 +- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/cable.html | 4 ++-- netbox/templates/dcim/device.html | 12 ++++++------ netbox/templates/dcim/devicerole.html | 2 +- netbox/templates/dcim/devicetype.html | 4 ++-- netbox/templates/dcim/interface.html | 8 ++++---- netbox/templates/dcim/powerfeed.html | 2 +- netbox/templates/dcim/rack.html | 8 ++++---- netbox/templates/dcim/site.html | 10 +++++----- netbox/templates/dcim/virtualchassis_edit.html | 2 +- netbox/templates/extras/customfield.html | 4 ++-- netbox/templates/extras/htmx/report_result.html | 2 +- netbox/templates/generic/bulk_import.html | 4 ++-- netbox/templates/inc/panels/custom_fields.html | 2 +- netbox/templates/ipam/ipaddress.html | 6 +++--- netbox/templates/ipam/prefix.html | 8 ++++---- netbox/templates/ipam/role.html | 4 ++-- netbox/templates/ipam/service.html | 2 +- netbox/templates/ipam/vlan.html | 4 ++-- netbox/templates/tenancy/contact.html | 4 ++-- netbox/templates/users/profile.html | 2 +- netbox/templates/virtualization/virtualmachine.html | 8 ++++---- .../wireless/inc/wirelesslink_interface.html | 4 ++-- 25 files changed, 57 insertions(+), 57 deletions(-) diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html index 27eebb3d8..b2b30d635 100644 --- a/netbox/templates/circuits/circuit_terminations_swap.html +++ b/netbox/templates/circuits/circuit_terminations_swap.html @@ -10,7 +10,7 @@ {% if termination_a %} {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • @@ -18,7 +18,7 @@ {% if termination_z %} {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index fdb01e803..b673cd4a3 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -94,7 +94,7 @@ {% elif termination.port_speed %} {{ termination.port_speed|humanize_speed }} {% else %} - + {{ ''|placeholder }} {% endif %}
    diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 1bf63f2d5..60bf8cfbc 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -50,7 +50,7 @@ {% if object.portal_url %} {{ object.portal_url }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index f1cf986e6..cd171cbb3 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -40,7 +40,7 @@ {% if object.color %}   {% else %} - + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.length %} {{ object.length|floatformat }} {{ object.get_length_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d075a801d..d3d6f03dc 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -23,7 +23,7 @@ {% endfor %} {{ object.site.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -40,7 +40,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.rack %} {{ object.rack }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -69,7 +69,7 @@ {% elif object.rack and object.device_type.u_height %} Not racked {% else %} - + {{ ''|placeholder }} {% endif %} @@ -180,7 +180,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -195,7 +195,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 288101c08..610c53071 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -54,7 +54,7 @@ {% if object.vm_role %} {{ virtualmachine_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index e717a48aa..bb3ec9d2e 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -55,7 +55,7 @@ {{ object.front_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -67,7 +67,7 @@ {{ object.rear_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 358922730..c4cb8b72f 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -321,7 +321,7 @@ {% if object.rf_channel_frequency %} {{ object.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -329,7 +329,7 @@ {% if peer.rf_channel_frequency %} {{ peer.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} @@ -340,7 +340,7 @@ {% if object.rf_channel_width %} {{ object.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -348,7 +348,7 @@ {% if peer.rf_channel_width %} {{ peer.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 777af5563..ed1f9a1cd 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -44,7 +44,7 @@ {% if object.connected_endpoint %} {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 6574e9b74..42f6a8e99 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -53,7 +53,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -115,7 +115,7 @@ {% if object.type %} {{ object.get_type_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -133,7 +133,7 @@ {% if object.outer_width %} {{ object.outer_width }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -143,7 +143,7 @@ {% if object.outer_depth %} {{ object.outer_depth }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index c15cab468..ab04ea018 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -34,7 +34,7 @@ {% endfor %} {{ object.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -47,7 +47,7 @@ {% endfor %} {{ object.group|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -79,7 +79,7 @@ {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
    Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -94,7 +94,7 @@ {{ object.physical_address|linebreaksbr }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -113,7 +113,7 @@ {{ object.latitude }}, {{ object.longitude }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 327f20531..275391c61 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -57,7 +57,7 @@ {% if device.rack %} {{ device.rack }} / {{ device.position }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index e8c3df460..a5618c6db 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -57,7 +57,7 @@ {% if object.choices %} {{ object.choices|join:", " }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -105,7 +105,7 @@ {% if object.validation_regex %} {{ object.validation_regex }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index 9b3e9db5f..c20bf5fe2 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -57,7 +57,7 @@ {% elif obj %} {{ obj }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 43e078826..1a85c3a21 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -76,14 +76,14 @@ Context: {% if field.required %} {% checkmark True true="Required" %} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index ab47c11af..d1d49e7cc 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -52,7 +52,7 @@ {% if object.role %} {{ object.get_role_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -73,7 +73,7 @@ {% endif %} {{ object.assigned_object|linkify }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -86,7 +86,7 @@ ({{ object.nat_inside.assigned_object.parent_object|linkify }}) {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index e2ba76a82..a47566ff7 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -39,7 +39,7 @@ {% if aggregate %} {{ aggregate.prefix }} ({{ aggregate.rir }}) {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -52,7 +52,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -65,7 +65,7 @@ {% endif %} {{ object.vlan|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -138,7 +138,7 @@ {{ first_available_ip }} {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 49570099d..a6ef2c6d4 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -45,7 +45,7 @@ {% if ipranges_count %} {{ ipranges_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} @@ -57,7 +57,7 @@ {% if vlans_count %} {{ vlans_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 71ea20fa5..47ae70dc9 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -44,7 +44,7 @@ {% for ipaddress in object.ipaddresses.all %} {{ ipaddress|linkify }}
    {% empty %} - None + {{ ''|placeholder }} {% endfor %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index f74149ad6..fd0ba36a3 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -21,7 +21,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -56,7 +56,7 @@ {% if object.role %} {{ object.role }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index f55e87895..8e71628e9 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -35,7 +35,7 @@ {% if object.phone %} {{ object.phone }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -45,7 +45,7 @@ {% if object.email %} {{ object.email }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html index 112603126..913784c94 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/profile.html @@ -21,7 +21,7 @@ {% if request.user.first_name or request.user.last_name %} {{ request.user.first_name }} {{ request.user.last_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0dec4968c..61f9aa61a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -49,7 +49,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -64,7 +64,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -115,7 +115,7 @@ {% if object.memory %} {{ object.memory|humanize_megabytes }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -125,7 +125,7 @@ {% if object.disk %} {{ object.disk }} GB {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index db4f84f0a..7732816a7 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -33,7 +33,7 @@ {% if interface.rf_channel_frequency %} {{ interface.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} @@ -43,7 +43,7 @@ {% if interface.rf_channel_width %} {{ interface.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} From 3608a3d430f952e6ac05b753abd338a7616af1fc Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 16 Jun 2022 22:26:37 +0200 Subject: [PATCH 078/113] Move markdown documentation to docs --- docs/reference/markdown.md | 353 ++++++++++++++++++++++++ mkdocs.yml | 1 + netbox/utilities/forms/fields/fields.py | 6 +- 3 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 docs/reference/markdown.md diff --git a/docs/reference/markdown.md b/docs/reference/markdown.md new file mode 100644 index 000000000..896d5dcf7 --- /dev/null +++ b/docs/reference/markdown.md @@ -0,0 +1,353 @@ +--- +hide: + - toc +--- + +# Markdown + +NetBox supports markdown rendering for certain text fields. + +## Syntax + +##### Table of Contents +[Headers](#headers) +[Emphasis](#emphasis) +[Lists](#lists) +[Links](#links) +[Images](#images) +[Code Blocks](#code) +[Tables](#tables) +[Blockquotes](#blockquotes) +[Inline HTML](#html) +[Horizontal Rule](#hr) +[Line Breaks](#lines) + + + +## Headers + +```no-highlight +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +Alternatively, for H1 and H2, an underline-ish style: + +Alt-H1 +====== + +Alt-H2 +------ +``` + +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + + + +## Emphasis + +```no-highlight +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ +``` + +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ + + + + +## Lists + +(In this example, leading and trailing spaces are shown with with dots: â‹…) + +```no-highlight +1. First ordered list item +2. Another item +â‹…â‹…* Unordered sub-list. +1. Actual numbers don't matter, just that it's a number +â‹…â‹…1. Ordered sub-list +4. And another item. + +â‹…â‹…â‹…You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + +â‹…â‹…â‹…To have a line break without a paragraph, you will need to use two trailing spaces.â‹…â‹… +â‹…â‹…â‹…Note that this line is separate, but within the same paragraph.â‹…â‹… +â‹…â‹…â‹…(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses +``` + +1. First ordered list item +2. Another item + * Unordered sub-list. +1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list +4. And another item. + + You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + + To have a line break without a paragraph, you will need to use two trailing spaces. + Note that this line is separate, but within the same paragraph. + (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses + + + +## Links + +There are two ways to create links. + +```no-highlight +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com +``` + +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com + + + +## Images + +``` +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" +``` + +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" + + + +## Code blocks + +``` +Inline `code` has `back-ticks around` it. +``` + +Inline `code` has `back-ticks around` it. + +Blocks of code are fenced by lines with three back-ticks ``` + +```` +``` +var s = "Code block"; +alert(s); +``` +```` + +``` +var s = "Code block"; +alert(s); +``` + + + +## Tables + +```no-highlight +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. +The outer pipes (|) are optional, and you don't need to make the +raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 +``` + +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 + + + +## Blockquotes + +```no-highlight +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. +``` + +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. + + + +## Inline HTML + +You can also use raw HTML in your Markdown, and it'll mostly work pretty well. + +```no-highlight +
    +
    Definition list
    +
    Is something people use sometimes.
    + +
    Markdown in HTML
    +
    Does *not* work **very** well. Use HTML tags.
    +
    +``` + +
    +
    Definition list
    +
    Is something people use sometimes.
    + +
    Markdown in HTML
    +
    Does *not* work **very** well. Use HTML tags.
    +
    + + + +## Horizontal Rule + +``` +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores +``` + +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores + + + +## Line Breaks + + +``` +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. +``` + +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also begins a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. + +Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/) \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 5c973e0d6..507b25627 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -136,6 +136,7 @@ nav: - Overview: 'graphql-api/overview.md' - Reference: - Conditions: 'reference/conditions.md' + - Markdown: 'reference/markdown.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index 0d09d2ac7..9168189a1 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -3,6 +3,7 @@ import json from django import forms from django.db.models import Count from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from django.templatetags.static import static from netaddr import AddrFormatError, EUI from utilities.forms import widgets @@ -26,10 +27,9 @@ class CommentField(forms.CharField): A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. """ widget = forms.Textarea - # TODO: Port Markdown cheat sheet to internal documentation - help_text = """ + help_text = f""" - + Markdown syntax is supported """ From 7d0098e139794737a2664f5b6466c0708f5cb106 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:04:57 -0400 Subject: [PATCH 079/113] Changelog for #8704, #9533, #9374, #9466, #9537 --- docs/release-notes/version-3.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 05d798e54..93021320f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,18 +4,23 @@ ### Enhancements +* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view * [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters +* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation ### Bug Fixes +* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data +* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form +* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI --- From 00f9fc5834060fb9984cda71d83442442857aea1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:36:55 -0400 Subject: [PATCH 080/113] Fixes #9503: Hyperlinks in ack elevation SVGs must always use absolute URLs --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/svg.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 93021320f..e69843198 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column +* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form * [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 7cd0fa417..1de68ec36 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -114,7 +114,7 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: image = drawing.image( - href=device.device_type.front_image.url, + href='{}{}'.format(self.base_url, device.device_type.front_image.url), insert=start, size=end, class_='device-image' @@ -140,7 +140,7 @@ class RackElevationSVG: # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: image = drawing.image( - href=device.device_type.rear_image.url, + href='{}{}'.format(self.base_url, device.device_type.rear_image.url), insert=start, size=end, class_='device-image' @@ -151,9 +151,9 @@ class RackElevationSVG: stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) - @staticmethod - def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): - link_url = '{}?{}'.format( + def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation): + link_url = '{}{}?{}'.format( + self.base_url, reverse('dcim:device_add'), urlencode({ 'site': rack.site.pk, From c4a9f4faabb42319d9782d0c5ed0be90f04c39bb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:40:37 -0400 Subject: [PATCH 081/113] Fixes #9549: Fix device counts for rack list under rack role view --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/views.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e69843198..4e96145e0 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -22,6 +22,7 @@ * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form * [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI +* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view --- diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 57e8b1c79..35a1056b2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView): queryset = RackRole.objects.all() def get_extra_context(self, request, instance): - racks = Rack.objects.restrict(request.user, 'view').filter( - role=instance + racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate( + device_count=count_related(Device, 'rack') ) racks_table = tables.RackTable(racks, user=request.user, exclude=( From b2fddb52619f791119c47b99c6a5d407669e6cbb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Jun 2022 14:51:45 -0400 Subject: [PATCH 082/113] Closes #9534: Add VLAN group selector to interface bulk edit forms --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/bulk_edit.py | 31 +++++++++++++++++++----- netbox/virtualization/forms/bulk_edit.py | 21 +++++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 4e96145e0..40715c8d3 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -9,6 +9,7 @@ * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view * [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters * [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation +* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms ### Bug Fixes diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9e4f5e400..231d01ddd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, VLAN, VRF +from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm( required=False, widget=BulkEditNullBooleanSelect ) + mode = forms.ChoiceField( + choices=add_blank_choice(InterfaceModeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm( ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', - 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', - 'vrf', + 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', + 'tagged_vlans', 'vrf', ) def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d5d33df2a..4894d78cf 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -3,7 +3,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import VLAN, VRF +from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -182,13 +182,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -200,7 +213,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( (None, ('mtu', 'enabled', 'vrf', 'description')), ('Related Interfaces', ('parent', 'bridge')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ) nullable_fields = ( 'parent', 'bridge', 'mtu', 'vrf', 'description', From 9db7f687d2890a63b213bba52873698fb2c30639 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 17 Jun 2022 22:53:51 +0200 Subject: [PATCH 083/113] Don't close select field when multiple select --- netbox/project-static/dist/netbox.js | Bin 376088 -> 376144 bytes netbox/project-static/dist/netbox.js.map | Bin 345522 -> 345564 bytes .../src/select/api/apiSelect.ts | 5 +++++ 3 files changed, 5 insertions(+) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index ce02d4bbb227926941d2adc3ae38e721d48ce21b..bc0cabef08f2cab679e475fe18741c4e97ee187e 100644 GIT binary patch delta 60 zcmbR7OYFigv4$4L7N!>F7M3lnkK81a5{pyya!YecG7EB2)zmafGBS(xigPk^r!$(c QiZa_O8g9Ss#%fyz0LEk$X#fBK delta 25 hcmcccOKiq3v4$4L7N!>F7M3lnkKDFPxwD#;0RWK~3C#ci diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index e21571e0c84f1680154cb438612199fef77a0f4e..26bb1c514297c61a2e7d50a8863067ed8656e455 100644 GIT binary patch delta 101 zcmdngEqbS0w4sHug{g&k3ybc`Glu|1!MHP;dVu00d@ diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 88b35a0e9..f5b605d58 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -205,6 +205,11 @@ export class APISelect { onChange: () => this.handleSlimChange(), }); + // Don't close on select if multiple select + if (this.base.multiple) { + this.slim.config.closeOnSelect = false; + } + // Initialize API query properties. this.getStaticParams(); this.getDynamicParams(); From 643209f5c9a641cf9a64bd503b39168bfca36a75 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 17 Jun 2022 23:16:57 +0200 Subject: [PATCH 084/113] Sanitize HTML after rendering markdown --- base_requirements.txt | 4 +++ .../templatetags/builtins/filters.py | 19 ++++-------- netbox/utilities/utils.py | 31 +++++++++++++++++++ requirements.txt | 1 + 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 6bb537a6a..68fca9851 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -125,3 +125,7 @@ tablib # Timezone data (required by django-timezone-field on Python 3.9+) # https://github.com/python/tzdata tzdata + +# HTML sanitizer +# https://github.com/mozilla/bleach +bleach \ No newline at end of file diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 44ad5ac47..738dc0e00 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -11,7 +11,7 @@ from markdown import markdown from netbox.config import get_config from utilities.markdown import StrikethroughExtension -from utilities.utils import foreground_color +from utilities.utils import clean_html, foreground_color register = template.Library() @@ -144,18 +144,6 @@ def render_markdown(value): {{ md_source_text|markdown }} """ - schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) - - # Strip HTML tags - value = strip_tags(value) - - # Sanitize Markdown links - pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)' - value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) - - # Sanitize Markdown reference links - pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)' - value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) @@ -164,6 +152,11 @@ def render_markdown(value): if html: html = f'
    {html}
    ' + schemes = get_config().ALLOWED_URL_SCHEMES + + # Sanitize HTML + html = clean_html(html, schemes) + return mark_safe(html) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index bc6d928ed..2b939471c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -4,6 +4,7 @@ from collections import OrderedDict from decimal import Decimal from itertools import count, groupby +import bleach from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce @@ -385,3 +386,33 @@ def copy_safe_request(request): 'path': request.path, 'id': getattr(request, 'id', None), # UUID assigned by middleware }) + + +def clean_html(html, schemes): + """ + Sanitizes HTML based on a whitelist of allowed tags and attributes. + Also takes a list of allowed URI schemes. + """ + + ALLOWED_TAGS = [ + "div", "pre", "code", "blockquote", "del", + "hr", "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", "p", "br", + "strong", "em", "a", "b", "i", "img", + "table", "thead", "tbody", "tr", "th", "td", + "dl", "dt", "dd", + ] + + ALLOWED_ATTRIBUTES = { + "div": ['class'], + "h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"], + "a": ["href", "title"], + "img": ["src", "title", "alt"], + } + + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=schemes + ) diff --git a/requirements.txt b/requirements.txt index 293a33542..dbe7d70c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +bleach==5.0.0 Django==4.0.4 django-cors-headers==3.12.0 django-debug-toolbar==3.2.4 From 7d0124240c78a10f3e39fa7aad42671d60c29705 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 18 Jun 2022 23:05:18 -0400 Subject: [PATCH 085/113] Implemented feature #9525 --- netbox/netbox/tables/tables.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8c5fb039c..b2bf6e967 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -165,7 +165,14 @@ class NetBoxTable(BaseTable): linkify=True, verbose_name='ID' ) - actions = columns.ActionsColumn() + actions = columns.ActionsColumn( + extra_buttons=""" + + + + + """ + ) exempt_columns = ('pk', 'actions') From f217167123a3823fbfea5a8ad16b3dfc5d8e7f6e Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 18 Jun 2022 23:08:06 -0400 Subject: [PATCH 086/113] Implemented feature #9525 --- netbox/netbox/tables/tables.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index b2bf6e967..de73bf6fe 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -167,10 +167,7 @@ class NetBoxTable(BaseTable): ) actions = columns.ActionsColumn( extra_buttons=""" - - - - + """ ) From 3541636f5283a98dd1fd9a5dfd4f71724219b40d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jun 2022 19:12:52 -0400 Subject: [PATCH 087/113] Closes #9525: Added split button functionality to ActionsColumn --- netbox/netbox/tables/columns.py | 43 ++++++++++++++++++++++++++------- netbox/netbox/tables/tables.py | 6 +---- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index e82e8a1ea..9067b13f2 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -175,6 +175,7 @@ class ActionsColumn(tables.Column): :param actions: The ordered list of dropdown menu items to include :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the direct button link and icon (default: True) """ attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () @@ -184,10 +185,11 @@ class ActionsColumn(tables.Column): 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): + def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs): super().__init__(*args, **kwargs) self.extra_buttons = extra_buttons + self.split_actions = split_actions # Determine which actions to enable self.actions = { @@ -210,19 +212,42 @@ class ActionsColumn(tables.Column): # Compile actions menu links = [] user = getattr(request, 'user', AnonymousUser()) - for action, attrs in self.actions.items(): + for idx, (action, attrs) in enumerate(self.actions.items()): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append( - f'
  • ' - f' {attrs.title}
  • ' - ) + + # If only a single action exists, render a regular button + if len(self.actions.items()) == 1: + html += ( + f'' + f'' + ) + + # Creates split button for the first action with direct link and icon + elif self.split_actions and idx == 0: + html += ( + f'' + f'' + f'' + ) + + # Creates standard action dropdown menu items + else: + links.append( + f'
  • ' + f' {attrs.title}
  • ' + ) + + # Create the actions dropdown menu if links: + dropdown_icon = '' if self.split_actions else '' + dropdown_class = '' if self.split_actions else '' html += ( - f'' - f'' - f'' + f'{dropdown_class}' + f'' + f'{dropdown_icon}' + f'Toggle Dropdown' f'' ) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index de73bf6fe..8c5fb039c 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -165,11 +165,7 @@ class NetBoxTable(BaseTable): linkify=True, verbose_name='ID' ) - actions = columns.ActionsColumn( - extra_buttons=""" - - """ - ) + actions = columns.ActionsColumn() exempt_columns = ('pk', 'actions') From 938d68b23392378a22a997d606cd0df3a0663c92 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jun 2022 20:00:15 -0400 Subject: [PATCH 088/113] Closes #9417: Pre-populate manufacturer when adding modules to devices --- 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 92739c6ed..7124c2b1f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """ {% else %} - + {% endif %} From bf057db8de0948d888ef55e698fd3dd9e65b9254 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jun 2022 22:10:01 -0400 Subject: [PATCH 089/113] Closes #9517: Linkify Power Port on Power Outlet Object View --- netbox/templates/dcim/poweroutlet.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 6408bc759..c312bee03 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -44,7 +44,7 @@
    - + From 7d65f573a68fc3dfdc1cfc1a4a563a20f5aef83a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 08:06:49 -0400 Subject: [PATCH 090/113] Changelog for #9417, #9517, #9525 --- docs/release-notes/version-3.2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 40715c8d3..66a9cdf66 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -7,7 +7,10 @@ * [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes * [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view +* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module * [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters +* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view +* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables * [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation * [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms From 3b3d576702cd65a2bb24cfe966b04799ee305b1f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 08:34:05 -0400 Subject: [PATCH 091/113] Changelog for #8944, #9108, #9556 --- docs/release-notes/version-3.2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 66a9cdf66..a491f4524 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -13,9 +13,12 @@ * [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables * [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation * [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms +* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields ### Bug Fixes +* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons +* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links * [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data * [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers From bca0568effae6355282760f54d68271691c25e43 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 09:44:59 -0400 Subject: [PATCH 092/113] #9525: Add button colors --- netbox/netbox/tables/columns.py | 58 ++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9067b13f2..7da241566 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -166,6 +166,7 @@ class ActionsItem: title: str icon: str permission: Optional[str] = None + css_class: Optional[str] = 'secondary' class ActionsColumn(tables.Column): @@ -175,13 +176,14 @@ class ActionsColumn(tables.Column): :param actions: The ordered list of dropdown menu items to include :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown - :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the direct button link and icon (default: True) + :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the + direct button link and icon (default: True) """ attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () actions = { - 'edit': ActionsItem('Edit', 'pencil', 'change'), - 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'), 'changelog': ActionsItem('Changelog', 'history'), } @@ -210,45 +212,49 @@ class ActionsColumn(tables.Column): html = '' # Compile actions menu - links = [] + button = None + dropdown_class = 'secondary' + dropdown_links = [] user = getattr(request, 'user', AnonymousUser()) for idx, (action, attrs) in enumerate(self.actions.items()): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - # If only a single action exists, render a regular button - if len(self.actions.items()) == 1: - html += ( - f'' + # Render a separate button if a) only one action exists, or b) if split_actions is True + if len(self.actions) == 1 or (self.split_actions and idx == 0): + dropdown_class = attrs.css_class + button = ( + f'' f'' ) - # Creates split button for the first action with direct link and icon - elif self.split_actions and idx == 0: - html += ( - f'' - f'' - f'' - ) - - # Creates standard action dropdown menu items + # Add dropdown menu items else: - links.append( + dropdown_links.append( f'
  • ' f' {attrs.title}
  • ' ) # Create the actions dropdown menu - if links: - dropdown_icon = '' if self.split_actions else '' - dropdown_class = '' if self.split_actions else '' + if button and dropdown_links: html += ( - f'{dropdown_class}' - f'' - f'{dropdown_icon}' - f'Toggle Dropdown' - f'' + f'' + f' {button}' + f' ' + f' Toggle Dropdown' + f' ' + f'' + ) + elif button: + html += button + elif dropdown_links: + html += ( + f'' + f' ' + f' Toggle Dropdown' + f' ' + f'' ) # Render any extra buttons from template code From 19f85ce3630d5d099f1f039d3c0483d55e56872b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 11:17:15 -0400 Subject: [PATCH 093/113] Closes #9453: Disable default loggers when running tests --- netbox/netbox/configuration_testing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 59529b80c..621671f04 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -36,3 +36,8 @@ REDIS = { } SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True +} From 1e13eeef877edfd5942100d8e382f13732bd1d60 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 11:22:36 -0400 Subject: [PATCH 094/113] Release v3.2.5 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 3 ++- docs/release-notes/version-3.2.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 12 ++++++------ 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a9af9c653..3b87a49e4 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.4 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1fff99f1d..1fc0268ab 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.4 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 68fca9851..98d3f78c2 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -44,7 +44,8 @@ django-tables2 # User-defined tags for objects # https://github.com/alex/django-taggit -django-taggit +# Will evaluate v3.0 during NetBox v3.3 beta +django-taggit>=2.1.0,<3.0 # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index a491f4524..bb5182702 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,6 @@ # NetBox v3.2 -## v3.2.5 (FUTURE) +## v3.2.5 (2022-06-20) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f30dea4d7..f86f3a8f2 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.2.5-dev' +VERSION = '3.2.5' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index dbe7d70c2..1fdace4f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ bleach==5.0.0 -Django==4.0.4 -django-cors-headers==3.12.0 -django-debug-toolbar==3.2.4 -django-filter==21.1 +Django==4.0.5 +django-cors-headers==3.13.0 +django-debug-toolbar==3.4.0 +django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 django-pglocks==1.0.4 @@ -19,7 +19,7 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.16 +mkdocs-material==8.3.6 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.1.1 @@ -27,7 +27,7 @@ psycopg2-binary==2.9.3 PyYAML==6.0 sentry-sdk==1.5.12 social-auth-app-django==5.0.0 -social-auth-core==4.2.0 +social-auth-core==4.3.0 svgwrite==1.4.2 tablib==3.2.1 tzdata==2022.1 From 70ec727caf4431b5ca3c0b85b9d479fb66b5dd9c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 11:38:49 -0400 Subject: [PATCH 095/113] PRVB --- docs/release-notes/version-3.2.md | 6 +++++- netbox/netbox/settings.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index bb5182702..059fc8924 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,9 @@ # NetBox v3.2 +## v3.2.6 (FUTURE) + +--- + ## v3.2.5 (2022-06-20) ### Enhancements @@ -25,7 +29,7 @@ * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view * [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column -* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs +* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs * [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN * [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form * [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f86f3a8f2..b2e1eca6c 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.2.5' +VERSION = '3.2.6-dev' # Hostname HOSTNAME = platform.node() From 365523a5d01a99322399878db5ab40cb20a9a561 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 17:27:58 -0400 Subject: [PATCH 096/113] Initial work on half-height RUs --- docs/release-notes/version-3.3.md | 11 ++ netbox/dcim/api/serializers.py | 24 ++- netbox/dcim/forms/models.py | 2 +- .../migrations/0154_half_height_rack_units.py | 23 +++ netbox/dcim/models/devices.py | 12 +- netbox/dcim/models/racks.py | 56 +++--- netbox/dcim/svg.py | 175 ++++++++++-------- netbox/dcim/tests/test_api.py | 8 +- netbox/dcim/tests/test_models.py | 31 +++- netbox/utilities/forms/utils.py | 1 - netbox/utilities/utils.py | 16 ++ 11 files changed, 232 insertions(+), 127 deletions(-) create mode 100644 netbox/dcim/migrations/0154_half_height_rack_units.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 514a92e88..229509b9c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,8 +4,13 @@ ### Breaking Changes +* 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). +### New Features + +#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -23,6 +28,12 @@ ### REST API Changes +* dcim.Device + * The `position` field has been changed from an integer to a decimal +* dcim.DeviceType + * The `u_height` field has been changed from an integer to a decimal +* dcim.Rack + * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * extras.CustomField * Added `group_name` and `ui_visibility` fields * ipam.IPAddress diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7fcab6ba3..ba7f661b5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,5 @@ +import decimal + from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -201,7 +203,11 @@ class RackUnitSerializer(serializers.Serializer): """ A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. """ - id = serializers.IntegerField(read_only=True) + id = serializers.DecimalField( + max_digits=4, + decimal_places=1, + read_only=True + ) name = serializers.CharField(read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -283,6 +289,13 @@ class ManufacturerSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() + u_height = serializers.DecimalField( + max_digits=4, + decimal_places=1, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=1.0 + ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) @@ -589,7 +602,14 @@ class DeviceSerializer(NetBoxModelSerializer): location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True, default=None) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') - position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None) + position = serializers.DecimalField( + max_digits=4, + decimal_places=1, + allow_null=True, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=None + ) status = ChoiceField(choices=DeviceStatusChoices, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 179893219..fe461b061 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -467,7 +467,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'location_id': '$location', } ) - position = forms.IntegerField( + position = forms.DecimalField( required=False, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py new file mode 100644 index 000000000..dd21fddcf --- /dev/null +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -0,0 +1,23 @@ +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e88af2d05..14147f388 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -99,8 +99,10 @@ class DeviceType(NetBoxModel): blank=True, help_text='Discrete part number (optional)' ) - u_height = models.PositiveSmallIntegerField( - default=1, + u_height = models.DecimalField( + max_digits=4, + decimal_places=1, + default=1.0, verbose_name='Height (U)' ) is_full_depth = models.BooleanField( @@ -654,10 +656,12 @@ class Device(NetBoxModel, ConfigContextModel): blank=True, null=True ) - position = models.PositiveSmallIntegerField( + position = models.DecimalField( + max_digits=4, + decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1)], + validators=[MinValueValidator(1), MaxValueValidator(99.5)], verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 81d699b11..f963fb396 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,4 @@ -from collections import OrderedDict +import decimal from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation @@ -13,11 +13,10 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.config import get_config from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string +from utilities.utils import array_to_string, drange from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed @@ -242,10 +241,13 @@ class Rack(NetBoxModel): @property def units(self): + """ + Return a list of unit numbers, top to bottom. + """ + max_position = self.u_height + decimal.Decimal(0.5) if self.desc_units: - return range(1, self.u_height + 1) - else: - return reversed(range(1, self.u_height + 1)) + drange(0.5, max_position, 0.5) + return drange(max_position, 0.5, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) @@ -263,12 +265,12 @@ class Rack(NetBoxModel): reference to the device. When False, only the bottom most unit for a device is included and that unit contains a height attribute for the device """ - - elevation = OrderedDict() + elevation = {} for u in self.units: + u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}' elevation[u] = { 'id': u, - 'name': f'U{u}', + 'name': u_name, 'face': face, 'device': None, 'occupied': False @@ -278,7 +280,7 @@ class Rack(NetBoxModel): if self.pk: # Retrieve all devices installed within the rack - queryset = Device.objects.prefetch_related( + devices = Device.objects.prefetch_related( 'device_type', 'device_type__manufacturer', 'device_role' @@ -299,9 +301,9 @@ class Rack(NetBoxModel): if user is not None: permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True) - for device in queryset: + for device in devices: if expand_devices: - for u in range(device.position, device.position + device.device_type.u_height): + for u in drange(device.position, device.position + device.device_type.u_height, 0.5): if user is None or device.pk in permitted_device_ids: elevation[u]['device'] = device elevation[u]['occupied'] = True @@ -310,8 +312,6 @@ class Rack(NetBoxModel): elevation[device.position]['device'] = device elevation[device.position]['occupied'] = True elevation[device.position]['height'] = device.device_type.u_height - for u in range(device.position + 1, device.position + device.device_type.u_height): - elevation.pop(u, None) return [u for u in elevation.values()] @@ -331,12 +331,12 @@ class Rack(NetBoxModel): devices = devices.exclude(pk__in=exclude) # Initialize the rack unit skeleton - units = list(range(1, self.u_height + 1)) + units = list(self.units) # Remove units consumed by installed devices for d in devices: if rack_face is None or d.face == rack_face or d.device_type.is_full_depth: - for u in range(d.position, d.position + d.device_type.u_height): + for u in drange(d.position, d.position + d.device_type.u_height, 0.5): try: units.remove(u) except ValueError: @@ -346,7 +346,7 @@ class Rack(NetBoxModel): # Remove units without enough space above them to accommodate a device of the specified height available_units = [] for u in units: - if set(range(u, u + u_height)).issubset(units): + if set(drange(u, u + u_height, 0.5)).issubset(units): available_units.append(u) return list(reversed(available_units)) @@ -356,9 +356,9 @@ class Rack(NetBoxModel): Return a dictionary mapping all reserved units within the rack to their reservation. """ reserved_units = {} - for r in self.reservations.all(): - for u in r.units: - reserved_units[u] = r + for reservation in self.reservations.all(): + for u in reservation.units: + reserved_units[u] = reservation return reserved_units def get_elevation_svg( @@ -384,13 +384,17 @@ class Rack(NetBoxModel): :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ - elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) - if unit_width is None or unit_height is None: - config = get_config() - unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH - unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + elevation = RackElevationSVG( + self, + unit_width=unit_width, + unit_height=unit_height, + legend_width=legend_width, + user=user, + include_images=include_images, + base_url=base_url + ) - return elevation.render(face, unit_width, unit_height, legend_width) + return elevation.render(face) def get_0u_devices(self): return self.devices.filter(position=0) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 1de68ec36..dfb788e38 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -1,3 +1,4 @@ +import decimal import svgwrite from svgwrite.container import Group, Hyperlink from svgwrite.shapes import Line, Rect @@ -7,6 +8,7 @@ from django.conf import settings from django.urls import reverse from django.utils.http import urlencode +from netbox.config import get_config from utilities.utils import foreground_color from .choices import DeviceFaceChoices from .constants import RACK_ELEVATION_BORDER_WIDTH @@ -36,13 +38,17 @@ class RackElevationSVG: :param include_images: If true, the SVG document will embed front/rear device face images, where available :param base_url: Base URL for links within the SVG document. If none, links will be relative. """ - def __init__(self, rack, user=None, include_images=True, base_url=None): + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, + base_url=None): self.rack = rack self.include_images = include_images - if base_url is not None: - self.base_url = base_url.rstrip('/') - else: - self.base_url = '' + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Set drawing dimensions + config = get_config() + self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT # Determine the subset of devices within this rack that are viewable by the user, if any permitted_devices = self.rack.devices @@ -78,15 +84,16 @@ class RackElevationSVG: gradient.add_stop_color(offset='100%', color=color) drawing.defs.add(gradient) - @staticmethod - def _setup_drawing(width, height): + def _setup_drawing(self): + width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 drawing = svgwrite.Drawing(size=(width, height)) - # add the stylesheet + # Add the stylesheet with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: drawing.defs.add(drawing.style(css_file.read())) - # add gradients + # Add gradients RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') @@ -151,7 +158,7 @@ class RackElevationSVG: stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) - def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation): + def _draw_empty(self, drawing, rack, start, end, text, unit, face_id, class_, reservation): link_url = '{}{}?{}'.format( self.base_url, reverse('dcim:device_add'), @@ -160,7 +167,7 @@ class RackElevationSVG: 'location': rack.location.pk if rack.location else '', 'rack': rack.pk, 'face': face_id, - 'position': id_ + 'position': unit }) ) link = drawing.add( @@ -173,98 +180,108 @@ class RackElevationSVG: link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.text("add device", insert=text, class_='add-device')) - def merge_elevations(self, face): - elevation = self.rack.get_rack_units(face=face, expand_devices=False) - if face == DeviceFaceChoices.FACE_REAR: - other_face = DeviceFaceChoices.FACE_FRONT - else: - other_face = DeviceFaceChoices.FACE_REAR - other = self.rack.get_rack_units(face=other_face) - - unit_cursor = 0 - for u in elevation: - o = other[unit_cursor] - if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: - u['device'] = o['device'] - u['height'] = 1 - unit_cursor += u.get('height', 1) - - return elevation - - def render(self, face, unit_width, unit_height, legend_width): + def draw_legend(self): """ - Return an SVG document representing a rack elevation. + Draw the rack unit labels along the lefthand side of the elevation. """ - drawing = self._setup_drawing( - unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2, - unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 - ) - reserved_units = self.rack.get_reserved_units() - - unit_cursor = 0 for ru in range(0, self.rack.u_height): - start_y = ru * unit_height - position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + start_y = ru * self.unit_height + position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru - drawing.add( - drawing.text(str(unit), position_coordinates, class_="unit") + self.drawing.add( + Text(str(unit), position_coordinates, class_="unit") ) - for unit in self.merge_elevations(face): + def draw_face(self, face, opposite=False): + """ + Draw any occupied rack units for the specified rack face. + """ + for unit in self.rack.get_rack_units(face=face, expand_devices=False): # Loop through all units in the elevation device = unit['device'] - height = unit.get('height', 1) + height = unit.get('height', decimal.Decimal(1.0)) # Setup drawing coordinates - x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH - y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH - end_y = unit_height * height + x_offset = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y_offset = int(unit['id'] * self.unit_height) + RACK_ELEVATION_BORDER_WIDTH + else: + y_offset = self.drawing['height'] - int(unit['id'] * self.unit_height) - RACK_ELEVATION_BORDER_WIDTH + + end_y = int(self.unit_height * height) start_cordinates = (x_offset, y_offset) - end_cordinates = (unit_width, end_y) - text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) + size = (self.unit_width, end_y) + text_cordinates = (x_offset + (self.unit_width / 2), y_offset + end_y / 2) # Draw the device - if device and device.face == face and device.pk in self.permitted_device_ids: - self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: - self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) + if device and device.pk in self.permitted_device_ids: + print(device) + print(f' {start_cordinates}') + print(f' {size}') + + if device.face == face and not opposite: + self._draw_device_front(self.drawing, device, start_cordinates, size, text_cordinates) + else: + self._draw_device_rear(self.drawing, device, start_cordinates, size, text_cordinates) + elif device: # Devices which the user does not have permission to view are rendered only as unavailable space - drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked')) - else: - # Draw shallow devices, reservations, or empty units - class_ = 'slot' - reservation = reserved_units.get(unit["id"]) - if device: - class_ += ' occupied' - if reservation: - class_ += ' reserved' - self._draw_empty( - drawing, - self.rack, - start_cordinates, - end_cordinates, - text_cordinates, - unit["id"], - face, - class_, - reservation - ) + self.drawing.add(Rect(start_cordinates, size, class_='blocked')) - unit_cursor += height + # else: + # # Draw shallow devices, reservations, or empty units + # class_ = 'slot' + # # reservation = reserved_units.get(unit["id"]) + # reservation = None + # if device: + # class_ += ' occupied' + # if reservation: + # class_ += ' reserved' + # self._draw_empty( + # self.drawing, + # self.rack, + # start_cordinates, + # end_cordinates, + # text_cordinates, + # unit["id"], + # face, + # class_, + # reservation + # ) + + def render(self, face): + """ + Return an SVG document representing a rack elevation. + """ + + # Initialize the drawing + self.drawing = self._setup_drawing() + + # reserved_units = self.rack.get_reserved_units() + + # Draw the unit legend + self.draw_legend() + + # Draw the opposite rack face first, then the near face + if face == DeviceFaceChoices.FACE_REAR: + opposite_face = DeviceFaceChoices.FACE_FRONT + else: + opposite_face = DeviceFaceChoices.FACE_REAR + # self.draw_face(opposite_face, opposite=True) + self.draw_face(face) # Wrap the drawing with a border border_width = RACK_ELEVATION_BORDER_WIDTH border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = drawing.rect( - insert=(legend_width + border_offset, border_offset), - size=(unit_width + border_width, self.rack.u_height * unit_height + border_width), + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), class_='rack' ) - drawing.add(frame) + self.drawing.add(frame) - return drawing + return self.drawing OFFSET = 0.5 diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 22537abe0..a6631208b 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -327,15 +327,15 @@ class RackTest(APIViewTestCases.APIViewTestCase): # Retrieve all units response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 42) + self.assertEqual(response.data['count'], 84) # Search for specific units response = self.client.get(f'{url}?q=3', **self.header) - self.assertEqual(response.data['count'], 13) + self.assertEqual(response.data['count'], 26) response = self.client.get(f'{url}?q=U3', **self.header) - self.assertEqual(response.data['count'], 11) + self.assertEqual(response.data['count'], 22) response = self.client.get(f'{url}?q=U10', **self.header) - self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['count'], 2) def test_get_rack_elevation_svg(self): """ diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 8566f969b..eefef3fb4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,3 +1,5 @@ +import decimal + from django.core.exceptions import ValidationError from django.test import TestCase @@ -5,6 +7,7 @@ from circuits.models import * from dcim.choices import * from dcim.models import * from tenancy.models import Tenant +from utilities.utils import drange class LocationTestCase(TestCase): @@ -183,26 +186,34 @@ class RackTestCase(TestCase): device_role=DeviceRole.objects.get(slug='switch'), site=self.site1, rack=self.rack, - position=10, + position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) + self.assertEqual(list(self.rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) - rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) - self.assertEqual(rack1_inventory_front[-10]['device'], device1) - del(rack1_inventory_front[-10]) - for u in rack1_inventory_front: + rack1_inventory_front = { + u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + } + self.assertEqual(rack1_inventory_front[10.0]['device'], device1) + self.assertEqual(rack1_inventory_front[10.5]['device'], device1) + del(rack1_inventory_front[10.0]) + del(rack1_inventory_front[10.5]) + for u in rack1_inventory_front.values(): self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) - self.assertEqual(rack1_inventory_rear[-10]['device'], device1) - del(rack1_inventory_rear[-10]) - for u in rack1_inventory_rear: + rack1_inventory_rear = { + u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + } + self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) + self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) + del(rack1_inventory_rear[10.0]) + del(rack1_inventory_rear[10.5]) + for u in rack1_inventory_rear.values(): self.assertIsNone(u['device']) def test_mount_zero_ru(self): diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 9a4b011e0..a6f037e0b 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -1,7 +1,6 @@ import re from django import forms -from django.conf import settings from django.forms.models import fields_for_model from utilities.choices import unpack_grouped_choices diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 2b939471c..6a1b560e1 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,4 +1,5 @@ import datetime +import decimal import json from collections import OrderedDict from decimal import Decimal @@ -226,6 +227,21 @@ def deepmerge(original, new): return merged +def drange(start, end, step=decimal.Decimal(1)): + """ + Decimal-compatible implementation of Python's range() + """ + start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step) + if start < end: + while start < end: + yield start + start += step + else: + while start > end: + yield start + start += step + + def to_meters(length, unit): """ Convert the given length to meters. From 8b4286087cecb946bf7f9654b01a72e217515c08 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 14:54:18 -0400 Subject: [PATCH 097/113] Clean up rack elevation rendering --- netbox/dcim/svg.py | 167 ++++++++++++++++++++++----------------------- 1 file changed, 83 insertions(+), 84 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index dfb788e38..95db476ad 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -94,18 +94,34 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') return drawing - def _draw_device_front(self, drawing, device, start, end, text): + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device_front(self, drawing, device, start, size): + text_coords = ( + start[0] + size[0] / 2, + start[1] + size[1] / 2 + ) name = get_device_name(device) if device.devicebay_count: name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - color = device.device_role.color + link = drawing.add( drawing.a( href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), @@ -114,25 +130,30 @@ class RackElevationSVG: ) ) link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) + link.add(drawing.rect(start, size, style='fill: #{}'.format(color), class_='slot')) hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text, fill=hex_color)) + link.add(drawing.text(str(name), insert=text_coords, fill=hex_color)) # Embed front device type image if one exists if self.include_images and device.device_type.front_image: image = drawing.image( href='{}{}'.format(self.base_url, device.device_type.front_image.url), insert=start, - size=end, + size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(drawing.text(str(name), insert=text, stroke='black', + link.add(drawing.text(str(name), insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label')) + link.add(drawing.text(str(name), insert=text_coords, fill='white', class_='device-image-label')) + + def _draw_device_rear(self, drawing, device, start, size): + text_coords = ( + start[0] + size[0] / 2, + start[1] + size[1] / 2 + ) - def _draw_device_rear(self, drawing, device, start, end, text): link = drawing.add( drawing.a( href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), @@ -141,57 +162,76 @@ class RackElevationSVG: ) ) link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text)) + link.add(drawing.rect(start, size, class_="slot blocked")) + link.add(drawing.text(get_device_name(device), insert=text_coords)) # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: image = drawing.image( href='{}{}'.format(self.base_url, device.device_type.rear_image.url), insert=start, - size=end, + size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(drawing.text(get_device_name(device), insert=text, stroke='black', + link.add(Text(get_device_name(device), insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) + link.add(Text(get_device_name(device), insert=text_coords, fill='white', class_='device-image-label')) - def _draw_empty(self, drawing, rack, start, end, text, unit, face_id, class_, reservation): - link_url = '{}{}?{}'.format( - self.base_url, - reverse('dcim:device_add'), - urlencode({ - 'site': rack.site.pk, - 'location': rack.location.pk if rack.location else '', - 'rack': rack.pk, - 'face': face_id, - 'position': unit - }) + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' ) - link = drawing.add( - drawing.a(href=link_url, target='_top') - ) - if reservation: - link.set_desc('{} — {} · {}'.format( - reservation.description, reservation.user, reservation.created - )) - link.add(drawing.rect(start, end, class_=class_)) - link.add(drawing.text("add device", insert=text, class_='add-device')) + self.drawing.add(frame) def draw_legend(self): """ Draw the rack unit labels along the lefthand side of the elevation. """ for ru in range(0, self.rack.u_height): - start_y = ru * self.unit_height + start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru self.drawing.add( - Text(str(unit), position_coordinates, class_="unit") + Text(str(unit), position_coordinates, class_='unit') ) + def draw_background(self, face): + """ + Draw the rack unit placeholders which form the "background" of the rack elevation. + """ + x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width + url_string = '{}?{}&position={{}}'.format( + reverse('dcim:device_add'), + urlencode({ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, + }) + ) + + for ru in range(0, self.rack.u_height): + 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.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) + link.add(self.drawing.text('add device', insert=text_coords, class_='add-device')) + + self.drawing.add(link) + def draw_face(self, face, opposite=False): """ Draw any occupied rack units for the specified rack face. @@ -202,54 +242,21 @@ class RackElevationSVG: device = unit['device'] height = unit.get('height', decimal.Decimal(1.0)) - # Setup drawing coordinates - x_offset = self.legend_width + RACK_ELEVATION_BORDER_WIDTH - if self.rack.desc_units: - y_offset = int(unit['id'] * self.unit_height) + RACK_ELEVATION_BORDER_WIDTH - else: - y_offset = self.drawing['height'] - int(unit['id'] * self.unit_height) - RACK_ELEVATION_BORDER_WIDTH - + start_cordinates = self._get_device_coords(unit['id'], height) end_y = int(self.unit_height * height) - start_cordinates = (x_offset, y_offset) size = (self.unit_width, end_y) - text_cordinates = (x_offset + (self.unit_width / 2), y_offset + end_y / 2) # Draw the device if device and device.pk in self.permitted_device_ids: - print(device) - print(f' {start_cordinates}') - print(f' {size}') - if device.face == face and not opposite: - self._draw_device_front(self.drawing, device, start_cordinates, size, text_cordinates) + self._draw_device_front(self.drawing, device, start_cordinates, size) else: - self._draw_device_rear(self.drawing, device, start_cordinates, size, text_cordinates) + self._draw_device_rear(self.drawing, device, start_cordinates, size) elif device: # Devices which the user does not have permission to view are rendered only as unavailable space self.drawing.add(Rect(start_cordinates, size, class_='blocked')) - # else: - # # Draw shallow devices, reservations, or empty units - # class_ = 'slot' - # # reservation = reserved_units.get(unit["id"]) - # reservation = None - # if device: - # class_ += ' occupied' - # if reservation: - # class_ += ' reserved' - # self._draw_empty( - # self.drawing, - # self.rack, - # start_cordinates, - # end_cordinates, - # text_cordinates, - # unit["id"], - # face, - # class_, - # reservation - # ) - def render(self, face): """ Return an SVG document representing a rack elevation. @@ -258,10 +265,9 @@ class RackElevationSVG: # Initialize the drawing self.drawing = self._setup_drawing() - # reserved_units = self.rack.get_reserved_units() - - # Draw the unit legend + # Draw the empty rack & legend self.draw_legend() + self.draw_background(face) # Draw the opposite rack face first, then the near face if face == DeviceFaceChoices.FACE_REAR: @@ -271,15 +277,8 @@ class RackElevationSVG: # self.draw_face(opposite_face, opposite=True) self.draw_face(face) - # Wrap the drawing with a border - border_width = RACK_ELEVATION_BORDER_WIDTH - border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = Rect( - insert=(self.legend_width + border_offset, border_offset), - size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), - class_='rack' - ) - self.drawing.add(frame) + # Draw the rack border last + self.draw_border() return self.drawing From 130915cf4d74ff4e382de2530b2db4eeb5503511 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 16:00:39 -0400 Subject: [PATCH 098/113] Refactor device rendering methods --- netbox/dcim/svg.py | 153 ++++++++++++++++++++++----------------------- 1 file changed, 75 insertions(+), 78 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 95db476ad..0986e94d3 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -1,6 +1,8 @@ import decimal import svgwrite from svgwrite.container import Group, Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient from svgwrite.shapes import Line, Rect from svgwrite.text import Text @@ -22,11 +24,27 @@ __all__ = ( def get_device_name(device): if device.virtual_chassis: - return f'{device.virtual_chassis.name}:{device.vc_position}' + name = f'{device.virtual_chassis.name}:{device.vc_position}' elif device.name: - return device.name + name = device.name else: - return str(device.device_type) + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) — {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) class RackElevationSVG: @@ -56,21 +74,9 @@ class RackElevationSVG: permitted_devices = permitted_devices.restrict(user, 'view') self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - @staticmethod - def _get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - @staticmethod def _add_gradient(drawing, id_, color): - gradient = drawing.linearGradient( + gradient = LinearGradient( start=(0, 0), end=(0, 25), spreadMethod='repeat', @@ -82,6 +88,7 @@ class RackElevationSVG: gradient.add_stop_color(offset='50%', color='#f7f7f7') gradient.add_stop_color(offset='50%', color=color) gradient.add_stop_color(offset='100%', color=color) + drawing.defs.add(gradient) def _setup_drawing(self): @@ -90,7 +97,7 @@ class RackElevationSVG: drawing = svgwrite.Drawing(size=(width, height)) # Add the stylesheet - with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: + with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: drawing.defs.add(drawing.style(css_file.read())) # Add gradients @@ -112,72 +119,60 @@ class RackElevationSVG: return x, y - def _draw_device_front(self, drawing, device, start, size): - text_coords = ( - start[0] + size[0] / 2, - start[1] + size[1] / 2 - ) + def _draw_device(self, device, coords, size, color=None, image=None): name = get_device_name(device) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - color = device.device_role.color - - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, size, style='fill: #{}'.format(color), class_='slot')) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text_coords, fill=hex_color)) - - # Embed front device type image if one exists - if self.include_images and device.device_type.front_image: - image = drawing.image( - href='{}{}'.format(self.base_url, device.device_type.front_image.url), - insert=start, - size=size, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(str(name), insert=text_coords, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text_coords, fill='white', class_='device-image-label')) - - def _draw_device_rear(self, drawing, device, start, size): + description = get_device_description(device) text_coords = ( - start[0] + size[0] / 2, - start[1] + size[1] / 2 + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 ) + text_color = f'#{foreground_color(color)}' if color else '#000000' - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) + # Create hyperlink element + link = Hyperlink( + href='{}{}'.format( + self.base_url, + reverse('dcim:device', kwargs={'pk': device.pk}) + ), + target='_blank', ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, size, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text_coords)) + link.set_desc(description) + if color: + link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + else: + link.add(Rect(coords, size, class_='slot blocked')) + link.add(Text(name, insert=text_coords, fill=text_color)) - # Embed rear device type image if one exists - if self.include_images and device.device_type.rear_image: - image = drawing.image( - href='{}{}'.format(self.base_url, device.device_type.rear_image.url), - insert=start, + # Embed device type image if provided + if self.include_images and image: + image = Image( + href='{}{}'.format(self.base_url, image.url), + insert=coords, size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(Text(get_device_name(device), insert=text_coords, stroke='black', + link.add(Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(Text(get_device_name(device), insert=text_coords, fill='white', class_='device-image-label')) + link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + + self.drawing.add(link) + + def draw_device_front(self, device, coords, size): + """ + Draw the front (mounted) face of a device. + """ + color = device.device_role.color + image = device.device_type.front_image + self._draw_device(device, coords, size, color=color, image=image) + + def draw_device_rear(self, device, coords, size): + """ + Draw the rear (opposite) face of a device. + """ + image = device.device_type.rear_image + self._draw_device(device, coords, size, image=image) def draw_border(self): """ @@ -228,7 +223,7 @@ class RackElevationSVG: link = Hyperlink(href=url_string.format(ru), target='_blank') link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) - link.add(self.drawing.text('add device', insert=text_coords, class_='add-device')) + link.add(Text('add device', insert=text_coords, class_='add-device')) self.drawing.add(link) @@ -242,20 +237,22 @@ class RackElevationSVG: device = unit['device'] height = unit.get('height', decimal.Decimal(1.0)) - start_cordinates = self._get_device_coords(unit['id'], height) - end_y = int(self.unit_height * height) - size = (self.unit_width, end_y) + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) # Draw the device if device and device.pk in self.permitted_device_ids: if device.face == face and not opposite: - self._draw_device_front(self.drawing, device, start_cordinates, size) + self.draw_device_front(device, device_coords, device_size) else: - self._draw_device_rear(self.drawing, device, start_cordinates, size) + self.draw_device_rear(device, device_coords, device_size) elif device: # Devices which the user does not have permission to view are rendered only as unavailable space - self.drawing.add(Rect(start_cordinates, size, class_='blocked')) + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) def render(self, face): """ From 077a10ef265e696dda02e31fd19b2e0ffd5aa171 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 16:14:51 -0400 Subject: [PATCH 099/113] Fix rack utilization calculation --- netbox/dcim/models/racks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index f963fb396..12cc4dd38 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -405,6 +405,7 @@ class Rack(NetBoxModel): as utilized. """ # Determine unoccupied units + total_units = len(list(self.units)) available_units = self.get_available_units() # Remove reserved units @@ -412,8 +413,8 @@ class Rack(NetBoxModel): if u in available_units: available_units.remove(u) - occupied_unit_count = self.u_height - len(available_units) - percentage = float(occupied_unit_count) / self.u_height * 100 + occupied_unit_count = total_units - len(available_units) + percentage = float(occupied_unit_count) / total_units * 100 return percentage From 203b7d286e7853ef22bbc26faf105d29e497c40b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 21:28:57 -0400 Subject: [PATCH 100/113] Fix YAML representation of decimal values --- netbox/dcim/models/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 14147f388..43b84974b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -168,7 +168,7 @@ class DeviceType(NetBoxModel): ('model', self.model), ('slug', self.slug), ('part_number', self.part_number), - ('u_height', self.u_height), + ('u_height', float(self.u_height)), ('is_full_depth', self.is_full_depth), ('subdevice_role', self.subdevice_role), ('airflow', self.airflow), From d0c03756fc1cb6784bd2ddda3fd5ebe14811cddd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 12:37:56 -0400 Subject: [PATCH 101/113] Clean up rack model tests --- netbox/dcim/tests/test_models.py | 166 ++++++++++++------------------- 1 file changed, 63 insertions(+), 103 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index eefef3fb4..da54fc98d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,5 +1,3 @@ -import decimal - from django.core.exceptions import ValidationError from django.test import TestCase @@ -77,126 +75,90 @@ class RackTestCase(TestCase): def setUp(self): - self.site1 = Site.objects.create( - name='TestSite1', - slug='test-site-1' + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.site2 = Site.objects.create( - name='TestSite2', - slug='test-site-2' + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - self.location1 = Location.objects.create( - name='TestGroup1', - slug='test-group-1', - site=self.site1 - ) - self.location2 = Location.objects.create( - name='TestGroup2', - slug='test-group-2', - site=self.site2 - ) - self.rack = Rack.objects.create( - name='TestRack1', + for location in locations: + location.save() + + Rack.objects.create( + name='Rack 1', facility_id='A101', - site=self.site1, - location=self.location1, + site=sites[0], + location=locations[0], u_height=42 ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), ) + DeviceType.objects.bulk_create(device_types) - self.device_type = { - 'ff2048': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' - ), - 'cc5000': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='CurrentCatapult 5000', - slug='cc5000', - u_height=0 - ), - } - self.role = { - 'Server': DeviceRole.objects.create( - name='Server', - slug='server', - ), - 'Switch': DeviceRole.objects.create( - name='Switch', - slug='switch', - ), - 'Console Server': DeviceRole.objects.create( - name='Console Server', - slug='console-server', - ), - 'PDU': DeviceRole.objects.create( - name='PDU', - slug='pdu', - ), - - } + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') def test_rack_device_outside_height(self): - - rack1 = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42 - ) - rack1.save() + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( - name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=rack1, + name='Device 1', + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=43, face=DeviceFaceChoices.FACE_FRONT, ) device1.save() with self.assertRaises(ValidationError): - rack1.clean() + rack.clean() def test_location_site(self): + site1 = Site.objects.get(name='Site 1') + location2 = Location.objects.get(name='Location 2') - rack_invalid_location = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42, - location=self.location2 + rack2 = Rack( + name='Rack 2', + site=site1, + location=location2, + u_height=42 ) - rack_invalid_location.save() + rack2.save() with self.assertRaises(ValidationError): - rack_invalid_location.clean() + rack2.clean() def test_mount_single_device(self): + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=self.rack, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(drange(42.5, 0.5, -0.5))) + self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) rack1_inventory_front = { - u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) } self.assertEqual(rack1_inventory_front[10.0]['device'], device1) self.assertEqual(rack1_inventory_front[10.5]['device'], device1) @@ -207,7 +169,7 @@ class RackTestCase(TestCase): # Validate inventory (rear face) rack1_inventory_rear = { - u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) } self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) @@ -217,16 +179,17 @@ class RackTestCase(TestCase): self.assertIsNone(u['device']) def test_mount_zero_ru(self): - pdu = Device.objects.create( + site = Site.objects.first() + rack = Rack.objects.first() + + device = Device.objects.create( name='TestPDU', - device_role=self.role.get('PDU'), - device_type=self.device_type.get('cc5000'), - site=self.site1, - rack=self.rack, - position=None, - face='', + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack ) - self.assertTrue(pdu) + self.assertTrue(device) def test_change_rack_site(self): """ @@ -235,19 +198,16 @@ class RackTestCase(TestCase): site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' - ) - device_role = DeviceRole.objects.create( - name='Device Role 1', slug='device-role-1', color='ff0000' - ) - # Create Rack1 in Site A rack1 = Rack.objects.create(site=site_a, name='Rack 1') # Create Device1 in Rack1 - device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role) + device1 = Device.objects.create( + site=site_a, + rack=rack1, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first() + ) # Move Rack1 to Site B rack1.site = site_b From b7425a0ec1b17cef6297ee5033ea5ef646f906a2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 13:57:37 -0400 Subject: [PATCH 102/113] Add test for 0.5U devices --- netbox/dcim/tests/test_models.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index da54fc98d..98d57801d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -100,6 +100,7 @@ class RackTestCase(TestCase): device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5), ) DeviceType.objects.bulk_create(device_types) @@ -179,17 +180,37 @@ class RackTestCase(TestCase): self.assertIsNone(u['device']) def test_mount_zero_ru(self): + """ + Check that a 0RU device can be mounted in a rack with no face/position. + """ site = Site.objects.first() rack = Rack.objects.first() - device = Device.objects.create( - name='TestPDU', + Device( + name='Device 1', device_role=DeviceRole.objects.first(), device_type=DeviceType.objects.first(), site=site, rack=rack - ) - self.assertTrue(device) + ).save() + + def test_mount_half_u_devices(self): + """ + Check that two 0.5U devices can be mounted in the same rack unit. + """ + rack = Rack.objects.first() + attrs = { + 'device_type': DeviceType.objects.get(u_height=0.5), + 'device_role': DeviceRole.objects.first(), + 'site': Site.objects.first(), + 'rack': rack, + 'face': DeviceFaceChoices.FACE_FRONT, + } + + Device(name='Device 1', position=1, **attrs).save() + Device(name='Device 2', position=1.5, **attrs).save() + + self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3) def test_change_rack_site(self): """ From de32c6188fcd31b6eafaf5d7fdda92c758f272be Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 14:30:25 -0400 Subject: [PATCH 103/113] Refactor dcim.svg module --- netbox/dcim/svg/__init__.py | 2 + netbox/dcim/{svg.py => svg/cables.py} | 272 +------------------------ netbox/dcim/svg/racks.py | 279 ++++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 270 deletions(-) create mode 100644 netbox/dcim/svg/__init__.py rename netbox/dcim/{svg.py => svg/cables.py} (54%) create mode 100644 netbox/dcim/svg/racks.py diff --git a/netbox/dcim/svg/__init__.py b/netbox/dcim/svg/__init__.py new file mode 100644 index 000000000..21e27d495 --- /dev/null +++ b/netbox/dcim/svg/__init__.py @@ -0,0 +1,2 @@ +from .cables import * +from .racks import * diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg/cables.py similarity index 54% rename from netbox/dcim/svg.py rename to netbox/dcim/svg/cables.py index 0986e94d3..eb0d2aca1 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg/cables.py @@ -1,285 +1,17 @@ -import decimal import svgwrite + +from django.conf import settings from svgwrite.container import Group, Hyperlink -from svgwrite.image import Image -from svgwrite.gradients import LinearGradient from svgwrite.shapes import Line, Rect from svgwrite.text import Text -from django.conf import settings -from django.urls import reverse -from django.utils.http import urlencode - -from netbox.config import get_config from utilities.utils import foreground_color -from .choices import DeviceFaceChoices -from .constants import RACK_ELEVATION_BORDER_WIDTH - __all__ = ( 'CableTraceSVG', - 'RackElevationSVG', ) -def get_device_name(device): - if device.virtual_chassis: - name = f'{device.virtual_chassis.name}:{device.vc_position}' - elif device.name: - name = device.name - else: - name = str(device.device_type) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - - return name - - -def get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - - -class RackElevationSVG: - """ - Use this class to render a rack elevation as an SVG image. - - :param rack: A NetBox Rack instance - :param user: User instance. If specified, only devices viewable by this user will be fully displayed. - :param include_images: If true, the SVG document will embed front/rear device face images, where available - :param base_url: Base URL for links within the SVG document. If none, links will be relative. - """ - def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, - base_url=None): - self.rack = rack - self.include_images = include_images - self.base_url = base_url.rstrip('/') if base_url is not None else '' - - # Set drawing dimensions - config = get_config() - self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH - self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT - - # Determine the subset of devices within this rack that are viewable by the user, if any - permitted_devices = self.rack.devices - if user is not None: - permitted_devices = permitted_devices.restrict(user, 'view') - self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - - @staticmethod - def _add_gradient(drawing, id_, color): - gradient = LinearGradient( - start=(0, 0), - end=(0, 25), - spreadMethod='repeat', - id_=id_, - gradientTransform='rotate(45, 0, 0)', - gradientUnits='userSpaceOnUse' - ) - gradient.add_stop_color(offset='0%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color=color) - gradient.add_stop_color(offset='100%', color=color) - - drawing.defs.add(gradient) - - def _setup_drawing(self): - width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 - height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 - drawing = svgwrite.Drawing(size=(width, height)) - - # Add the stylesheet - with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: - drawing.defs.add(drawing.style(css_file.read())) - - # Add gradients - RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') - RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') - - return drawing - - def _get_device_coords(self, position, height): - """ - Return the X, Y coordinates of the top left corner for a device in the specified rack unit. - """ - x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH - y = RACK_ELEVATION_BORDER_WIDTH - if self.rack.desc_units: - y += int((position - 1) * self.unit_height) - else: - y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) - - return x, y - - def _draw_device(self, device, coords, size, color=None, image=None): - name = get_device_name(device) - description = get_device_description(device) - text_coords = ( - coords[0] + size[0] / 2, - coords[1] + size[1] / 2 - ) - text_color = f'#{foreground_color(color)}' if color else '#000000' - - # Create hyperlink element - link = Hyperlink( - href='{}{}'.format( - self.base_url, - reverse('dcim:device', kwargs={'pk': device.pk}) - ), - target='_blank', - ) - link.set_desc(description) - if color: - link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) - else: - link.add(Rect(coords, size, class_='slot blocked')) - link.add(Text(name, insert=text_coords, fill=text_color)) - - # Embed device type image if provided - if self.include_images and image: - image = Image( - href='{}{}'.format(self.base_url, image.url), - insert=coords, - size=size, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(Text(name, insert=text_coords, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) - - self.drawing.add(link) - - def draw_device_front(self, device, coords, size): - """ - Draw the front (mounted) face of a device. - """ - color = device.device_role.color - image = device.device_type.front_image - self._draw_device(device, coords, size, color=color, image=image) - - def draw_device_rear(self, device, coords, size): - """ - Draw the rear (opposite) face of a device. - """ - image = device.device_type.rear_image - self._draw_device(device, coords, size, image=image) - - def draw_border(self): - """ - Draw a border around the collection of rack units. - """ - border_width = RACK_ELEVATION_BORDER_WIDTH - border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = Rect( - insert=(self.legend_width + border_offset, border_offset), - size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), - class_='rack' - ) - self.drawing.add(frame) - - def draw_legend(self): - """ - Draw the rack unit labels along the lefthand side of the elevation. - """ - for ru in range(0, self.rack.u_height): - start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH - position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) - unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru - self.drawing.add( - Text(str(unit), position_coordinates, class_='unit') - ) - - def draw_background(self, face): - """ - Draw the rack unit placeholders which form the "background" of the rack elevation. - """ - x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width - url_string = '{}?{}&position={{}}'.format( - reverse('dcim:device_add'), - urlencode({ - 'site': self.rack.site.pk, - 'location': self.rack.location.pk if self.rack.location else '', - 'rack': self.rack.pk, - 'face': face, - }) - ) - - for ru in range(0, self.rack.u_height): - 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.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')) - - self.drawing.add(link) - - def draw_face(self, face, opposite=False): - """ - Draw any occupied rack units for the specified rack face. - """ - for unit in self.rack.get_rack_units(face=face, expand_devices=False): - - # Loop through all units in the elevation - device = unit['device'] - height = unit.get('height', decimal.Decimal(1.0)) - - device_coords = self._get_device_coords(unit['id'], height) - device_size = ( - self.unit_width, - int(self.unit_height * height) - ) - - # Draw the device - if device and device.pk in self.permitted_device_ids: - if device.face == face and not opposite: - self.draw_device_front(device, device_coords, device_size) - else: - self.draw_device_rear(device, device_coords, device_size) - - elif device: - # Devices which the user does not have permission to view are rendered only as unavailable space - self.drawing.add(Rect(device_coords, device_size, class_='blocked')) - - def render(self, face): - """ - Return an SVG document representing a rack elevation. - """ - - # Initialize the drawing - self.drawing = self._setup_drawing() - - # Draw the empty rack & legend - self.draw_legend() - self.draw_background(face) - - # Draw the opposite rack face first, then the near face - if face == DeviceFaceChoices.FACE_REAR: - opposite_face = DeviceFaceChoices.FACE_FRONT - else: - opposite_face = DeviceFaceChoices.FACE_REAR - # self.draw_face(opposite_face, opposite=True) - self.draw_face(face) - - # Draw the rack border last - self.draw_border() - - return self.drawing - - OFFSET = 0.5 PADDING = 10 LINE_HEIGHT = 20 diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py new file mode 100644 index 000000000..4d518adf1 --- /dev/null +++ b/netbox/dcim/svg/racks.py @@ -0,0 +1,279 @@ +import decimal +import svgwrite +from svgwrite.container import Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient +from svgwrite.shapes import Rect +from svgwrite.text import Text + +from django.conf import settings +from django.urls import reverse +from django.utils.http import urlencode + +from netbox.config import get_config +from utilities.utils import foreground_color +from dcim.choices import DeviceFaceChoices +from dcim.constants import RACK_ELEVATION_BORDER_WIDTH + + +__all__ = ( + 'RackElevationSVG', +) + + +def get_device_name(device): + if device.virtual_chassis: + name = f'{device.virtual_chassis.name}:{device.vc_position}' + elif device.name: + name = device.name + else: + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) — {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) + + +class RackElevationSVG: + """ + Use this class to render a rack elevation as an SVG image. + + :param rack: A NetBox Rack instance + :param user: User instance. If specified, only devices viewable by this user will be fully displayed. + :param include_images: If true, the SVG document will embed front/rear device face images, where available + :param base_url: Base URL for links within the SVG document. If none, links will be relative. + """ + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, + base_url=None): + self.rack = rack + self.include_images = include_images + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Set drawing dimensions + config = get_config() + self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + + # Determine the subset of devices within this rack that are viewable by the user, if any + permitted_devices = self.rack.devices + if user is not None: + permitted_devices = permitted_devices.restrict(user, 'view') + self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) + + @staticmethod + def _add_gradient(drawing, id_, color): + gradient = LinearGradient( + start=(0, 0), + end=(0, 25), + spreadMethod='repeat', + id_=id_, + gradientTransform='rotate(45, 0, 0)', + gradientUnits='userSpaceOnUse' + ) + gradient.add_stop_color(offset='0%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color=color) + gradient.add_stop_color(offset='100%', color=color) + + drawing.defs.add(gradient) + + def _setup_drawing(self): + width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 + drawing = svgwrite.Drawing(size=(width, height)) + + # Add the stylesheet + with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: + drawing.defs.add(drawing.style(css_file.read())) + + # Add gradients + RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') + RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') + + return drawing + + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device(self, device, coords, size, color=None, image=None): + name = get_device_name(device) + description = get_device_description(device) + text_coords = ( + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 + ) + text_color = f'#{foreground_color(color)}' if color else '#000000' + + # Create hyperlink element + link = Hyperlink( + href='{}{}'.format( + self.base_url, + reverse('dcim:device', kwargs={'pk': device.pk}) + ), + target='_blank', + ) + link.set_desc(description) + if color: + link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + else: + link.add(Rect(coords, size, class_='slot blocked')) + link.add(Text(name, insert=text_coords, fill=text_color)) + + # Embed device type image if provided + if self.include_images and image: + image = Image( + href='{}{}'.format(self.base_url, image.url), + insert=coords, + size=size, + class_='device-image' + ) + image.fit(scale='slice') + link.add(image) + link.add(Text(name, insert=text_coords, stroke='black', + stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) + link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + + self.drawing.add(link) + + def draw_device_front(self, device, coords, size): + """ + Draw the front (mounted) face of a device. + """ + color = device.device_role.color + image = device.device_type.front_image + self._draw_device(device, coords, size, color=color, image=image) + + def draw_device_rear(self, device, coords, size): + """ + Draw the rear (opposite) face of a device. + """ + image = device.device_type.rear_image + self._draw_device(device, coords, size, image=image) + + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' + ) + self.drawing.add(frame) + + def draw_legend(self): + """ + Draw the rack unit labels along the lefthand side of the elevation. + """ + for ru in range(0, self.rack.u_height): + start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH + position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + self.drawing.add( + Text(str(unit), position_coordinates, class_='unit') + ) + + def draw_background(self, face): + """ + Draw the rack unit placeholders which form the "background" of the rack elevation. + """ + x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width + url_string = '{}?{}&position={{}}'.format( + reverse('dcim:device_add'), + urlencode({ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, + }) + ) + + for ru in range(0, self.rack.u_height): + 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.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')) + + self.drawing.add(link) + + def draw_face(self, face, opposite=False): + """ + Draw any occupied rack units for the specified rack face. + """ + for unit in self.rack.get_rack_units(face=face, expand_devices=False): + + # Loop through all units in the elevation + device = unit['device'] + height = unit.get('height', decimal.Decimal(1.0)) + + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) + + # Draw the device + if device and device.pk in self.permitted_device_ids: + if device.face == face and not opposite: + self.draw_device_front(device, device_coords, device_size) + else: + self.draw_device_rear(device, device_coords, device_size) + + elif device: + # Devices which the user does not have permission to view are rendered only as unavailable space + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) + + def render(self, face): + """ + Return an SVG document representing a rack elevation. + """ + + # Initialize the drawing + self.drawing = self._setup_drawing() + + # Draw the empty rack & legend + self.draw_legend() + self.draw_background(face) + + # Draw the opposite rack face first, then the near face + if face == DeviceFaceChoices.FACE_REAR: + opposite_face = DeviceFaceChoices.FACE_FRONT + else: + opposite_face = DeviceFaceChoices.FACE_REAR + # self.draw_face(opposite_face, opposite=True) + self.draw_face(face) + + # Draw the rack border last + self.draw_border() + + return self.drawing From e9f0b021d5bbba901dc6f673a5d9b25c4c288d64 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Jun 2022 16:30:27 -0400 Subject: [PATCH 104/113] Closes #4350: Illustrate reservations vertically alongside rack elevations --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/api/serializers.py | 5 +- netbox/dcim/constants.py | 3 +- netbox/dcim/models/racks.py | 5 +- netbox/dcim/svg/racks.py | 45 +++++++++++++----- netbox/project-static/dist/rack_elevation.css | Bin 1511 -> 1423 bytes .../project-static/styles/rack-elevation.scss | 16 ++----- netbox/utilities/utils.py | 24 +++++++++- 8 files changed, 71 insertions(+), 28 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 229509b9c..fc8c24f4c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -14,6 +14,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ba7f661b5..401c9a901 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -252,7 +252,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( - default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + ) + margin_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH ) exclude = serializers.IntegerField( required=False, diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 38bf16f0b..68bbd1dbe 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 -RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 +RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 +RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 # diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 12cc4dd38..39e01cae3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -367,7 +367,8 @@ class Rack(NetBoxModel): user=None, unit_width=None, unit_height=None, - legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, + legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH, + margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH, include_images=True, base_url=None ): @@ -381,6 +382,7 @@ class Rack(NetBoxModel): :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total height of the elevation :param legend_width: Width of the unit legend, in pixels + :param margin_width: Width of the rigth-hand margin, in pixels :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ @@ -389,6 +391,7 @@ class Rack(NetBoxModel): unit_width=unit_width, unit_height=unit_height, legend_width=legend_width, + margin_width=margin_width, user=user, include_images=include_images, base_url=base_url diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 4d518adf1..b344aad0a 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -11,7 +11,7 @@ from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config -from utilities.utils import foreground_color +from utilities.utils import foreground_color, array_to_ranges from dcim.choices import DeviceFaceChoices from dcim.constants import RACK_ELEVATION_BORDER_WIDTH @@ -55,8 +55,8 @@ class RackElevationSVG: :param include_images: If true, the SVG document will embed front/rear device face images, where available :param base_url: Base URL for links within the SVG document. If none, links will be relative. """ - def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, - base_url=None): + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None, + include_images=True, base_url=None): self.rack = rack self.include_images = include_images self.base_url = base_url.rstrip('/') if base_url is not None else '' @@ -65,7 +65,8 @@ class RackElevationSVG: config = get_config() self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH # Determine the subset of devices within this rack that are viewable by the user, if any permitted_devices = self.rack.devices @@ -91,7 +92,7 @@ class RackElevationSVG: drawing.defs.add(gradient) def _setup_drawing(self): - width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2 height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 drawing = svgwrite.Drawing(size=(width, height)) @@ -100,6 +101,7 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients + RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') @@ -198,6 +200,29 @@ class RackElevationSVG: Text(str(unit), position_coordinates, class_='unit') ) + def draw_margin(self): + """ + Draw any rack reservations in the right-hand margin alongside the rack elevation. + """ + for reservation in self.rack.reservations.all(): + for segment in array_to_ranges(reservation.units): + u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0] + coords = self._get_device_coords(segment[0], u_height) + coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) + size = ( + self.margin_width, + u_height * self.unit_height + ) + link = Hyperlink( + href='{}{}'.format(self.base_url, reservation.get_absolute_url()), + target='_blank' + ) + link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') + link.add( + Rect(coords, size, class_='reservation') + ) + self.drawing.add(link) + def draw_background(self, face): """ Draw the rack unit placeholders which form the "background" of the rack elevation. @@ -261,16 +286,12 @@ class RackElevationSVG: # Initialize the drawing self.drawing = self._setup_drawing() - # Draw the empty rack & legend + # Draw the empty rack, legend, and margin self.draw_legend() self.draw_background(face) + self.draw_margin() - # Draw the opposite rack face first, then the near face - if face == DeviceFaceChoices.FACE_REAR: - opposite_face = DeviceFaceChoices.FACE_FRONT - else: - opposite_face = DeviceFaceChoices.FACE_REAR - # self.draw_face(opposite_face, opposite=True) + # Draw the rack face self.draw_face(face) # Draw the rack border last diff --git a/netbox/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index 4f9361489cf7fe2a5553150afc12f6de625c9072..bfeed4150cf8f390dbb586e2e698af26ad196f89 100644 GIT binary patch delta 57 zcmaFP-Os&YDa&L{*1fvLW$6lfMXAN9MP-R4nfZCq$vKI|#j(|CnK?ODrA0X!$`Hxa I6wO*L0D|5Xxc~qF delta 114 zcmeC@e$KsNDN9&UYH?~&S!#+^Mt)gpQFL-nVsUY-wq9aNif&43S!Qx-by{Xlj+L^3 nfkAC?S-OH=aZY}T9!wWhy$-s}WI [(0, 2), (10,), (14, 16)]" + """ + group = ( + list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x) + ) + return [ + (g[0], g[-1])[:len(g)] for g in group + ] + + def array_to_string(array): """ Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField. For example: [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" """ - group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)) - return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + ret = [] + ranges = array_to_ranges(array) + for value in ranges: + if len(value) == 1: + ret.append(str(value[0])) + else: + ret.append(f'{value[0]}-{value[1]}') + return ', '.join(ret) def content_type_name(ct): From 5691240267d64862aa0d157733ccce2dcb6ccda3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Jun 2022 21:22:24 -0400 Subject: [PATCH 105/113] Closes #1099: Add PoE mode & type for interfaces --- docs/models/dcim/interface.md | 4 + docs/release-notes/version-3.3.md | 4 + netbox/dcim/api/serializers.py | 10 +- netbox/dcim/choices.py | 45 +++++++ netbox/dcim/filtersets.py | 10 +- netbox/dcim/forms/bulk_create.py | 7 +- netbox/dcim/forms/bulk_edit.py | 17 ++- netbox/dcim/forms/bulk_import.py | 16 ++- netbox/dcim/forms/filtersets.py | 9 ++ netbox/dcim/forms/models.py | 9 +- netbox/dcim/graphql/types.py | 6 + .../0155_interface_poe_mode_type.py | 23 ++++ netbox/dcim/models/device_components.py | 32 ++++- netbox/dcim/tables/devices.py | 8 +- netbox/dcim/tests/test_api.py | 2 + netbox/dcim/tests/test_filtersets.py | 119 ++++++++++++++++-- netbox/dcim/tests/test_views.py | 14 ++- netbox/templates/dcim/interface.html | 8 ++ 18 files changed, 310 insertions(+), 33 deletions(-) create mode 100644 netbox/dcim/migrations/0155_interface_poe_mode_type.py diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 7fa52fa9f..e3237c2ee 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -11,6 +11,10 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. +### Power over Ethernet (PoE) + +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 Wireless interfaces may additionally track the following attributes: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fc8c24f4c..801e45b51 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -11,6 +11,8 @@ #### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) +#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -33,6 +35,8 @@ * The `position` field has been changed from an integer to a decimal * dcim.DeviceType * The `u_height` field has been changed from an integer to a decimal +* dcim.Interface + * Added the option `poe_mode` and `poe_type` fields * dcim.Rack * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * extras.CustomField diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 401c9a901..f3d223d4c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -812,6 +812,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -836,10 +838,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', - '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', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', + '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', + 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2e96f9c67..44ec3fb88 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1003,6 +1003,51 @@ class InterfaceModeChoices(ChoiceSet): ) +class InterfacePoEModeChoices(ChoiceSet): + + MODE_PD = 'pd' + MODE_PSE = 'pse' + + CHOICES = ( + (MODE_PD, 'Powered device (PD)'), + (MODE_PSE, 'Power sourcing equipment (PSE)'), + ) + + +class InterfacePoETypeChoices(ChoiceSet): + + TYPE_1_8023AF = 'type1-ieee802.3af' + TYPE_2_8023AT = 'type2-ieee802.3at' + TYPE_3_8023BT = 'type3-ieee802.3bt' + TYPE_4_8023BT = 'type4-ieee802.3bt' + + PASSIVE_24V_2PAIR = 'passive-24v-2pair' + PASSIVE_24V_4PAIR = 'passive-24v-4pair' + PASSIVE_48V_2PAIR = 'passive-48v-2pair' + PASSIVE_48V_4PAIR = 'passive-48v-4pair' + + CHOICES = ( + ( + 'IEEE Standard', + ( + (TYPE_1_8023AF, '802.3af (Type 1)'), + (TYPE_2_8023AT, '802.3at (Type 2)'), + (TYPE_3_8023BT, '802.3bt (Type 3)'), + (TYPE_4_8023BT, '802.3bt (Type 4)'), + ) + ), + ( + 'Passive', + ( + (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), + (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'), + ) + ), + ) + + # # FrontPorts/RearPorts # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f052a8be9..7c2d02bb3 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1238,6 +1238,12 @@ class InterfaceFilterSet( ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() + poe_mode = django_filters.MultipleChoiceFilter( + choices=InterfacePoEModeChoices + ) + poe_type = django_filters.MultipleChoiceFilter( + choices=InterfacePoETypeChoices + ) vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1271,8 +1277,8 @@ class InterfaceFilterSet( class Meta: model = Interface fields = [ - 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', ] def filter_device(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 314a7a75f..43b852928 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, [ + 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', + ]), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', + 'poe_type', 'mark_connected', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 231d01ddd..88f043c32 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1063,6 +1063,18 @@ class InterfaceBulkEditForm( widget=BulkEditNullBooleanSelect, label='Management only' ) + poe_mode = forms.ChoiceField( + choices=add_blank_choice(InterfacePoEModeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + poe_type = forms.ChoiceField( + choices=add_blank_choice(InterfacePoETypeChoices), + required=False, + initial='', + widget=StaticSelect() + ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -1105,14 +1117,15 @@ class InterfaceBulkEditForm( (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('PoE', ('poe_mode', 'poe_type')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', - 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', - 'tagged_vlans', 'vrf', + 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index b28c16fad..292f58785 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -622,6 +622,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm): choices=InterfaceDuplexChoices, required=False ) + poe_mode = CSVChoiceField( + choices=InterfacePoEModeChoices, + required=False, + help_text='PoE mode' + ) + poe_type = CSVChoiceField( + choices=InterfacePoETypeChoices, + required=False, + help_text='PoE type' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -642,9 +652,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', - 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', + 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', + 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1535e5718..bdef32ec3 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -969,6 +969,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), + ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) @@ -1009,6 +1010,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + poe_mode = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False + ) + poe_type = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False + ) rf_role = MultipleChoiceField( choices=WirelessRoleChoices, required=False, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index fe461b061..c58500198 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1314,6 +1314,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('PoE', ('poe_mode', 'poe_type')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', @@ -1324,14 +1325,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = Interface fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', - 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', - 'vrf', 'tags', + 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': SelectSpeedWidget(), + 'poe_mode': StaticSelect(), + 'poe_type': StaticSelect(), 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index d25a6bba6..17d6bc646 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -226,6 +226,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType): exclude = ('_path',) filterset_class = filtersets.InterfaceFilterSet + def resolve_poe_mode(self, info): + return self.poe_mode or None + + def resolve_poe_type(self, info): + return self.poe_type or None + def resolve_mode(self, info): return self.mode or None diff --git a/netbox/dcim/migrations/0155_interface_poe_mode_type.py b/netbox/dcim/migrations/0155_interface_poe_mode_type.py new file mode 100644 index 000000000..0615d5d7e --- /dev/null +++ b/netbox/dcim/migrations/0155_interface_poe_mode_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.5 on 2022-06-22 00:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0154_half_height_rack_units'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9a0609c12..f49db08ab 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -590,6 +590,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo validators=(MaxValueValidator(127),), verbose_name='Transmit power (dBm)' ) + 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' + ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', on_delete=models.SET_NULL, @@ -638,7 +650,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo related_query_name='+' ) - clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] + clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -726,6 +738,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo f"of virtual chassis {self.device.virtual_chassis}." }) + # PoE validation + + # Only physical interfaces may have a PoE mode/type assigned + if self.poe_mode and self.is_virtual: + raise ValidationError({ + 'poe_mode': "Virtual interfaces cannot have a PoE mode." + }) + if self.poe_type and self.is_virtual: + raise ValidationError({ + 'poe_type': "Virtual interfaces cannot have a PoE type." + }) + + # An interface with a PoE type set must also specify a mode + if self.poe_type and not self.poe_mode: + raise ValidationError({ + 'poe_type': "Must specify PoE mode when designating a PoE type." + }) + # Wireless validation # RF role & channel may only be set for wireless interfaces diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0f015b7f3..b3dd700cb 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -520,10 +520,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', - 'tagged_vlans', 'created', 'last_updated', + 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', + 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a6631208b..a61b44f91 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1507,6 +1507,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'speed': 1000000, 'duplex': 'full', 'vrf': vrfs[0].pk, + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 273ee6570..f7d4c4e0a 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2540,14 +2540,109 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'), - Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'), - Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'), - Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), - Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), - Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), - Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20), + Interface( + device=devices[0], + module=modules[0], + name='Interface 1', + label='A', + type=InterfaceTypeChoices.TYPE_1GE_SFP, + enabled=True, + mgmt_only=True, + mtu=100, + mode=InterfaceModeChoices.MODE_ACCESS, + mac_address='00-00-00-00-00-01', + description='First', + vrf=vrfs[0], + speed=1000000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[1], + module=modules[1], + name='Interface 2', + label='B', + type=InterfaceTypeChoices.TYPE_1GE_GBIC, + enabled=True, + mgmt_only=True, + mtu=200, + mode=InterfaceModeChoices.MODE_TAGGED, + mac_address='00-00-00-00-00-02', + description='Second', + vrf=vrfs[1], + speed=1000000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[2], + module=modules[2], + name='Interface 3', + label='C', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + enabled=False, + mgmt_only=False, + mtu=300, + mode=InterfaceModeChoices.MODE_TAGGED_ALL, + mac_address='00-00-00-00-00-03', + description='Third', + vrf=vrfs[2], + speed=100000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 4', + label='D', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40, + speed=100000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 5', + label='E', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 6', + label='F', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=False, + mgmt_only=False, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 7', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_AP, + rf_channel=WirelessChannelChoices.CHANNEL_24G_1, + rf_channel_frequency=2412, + rf_channel_width=22 + ), + Interface( + device=devices[3], + name='Interface 8', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_STATION, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ), ) Interface.objects.bulk_create(interfaces) @@ -2594,6 +2689,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_poe_mode(self): + params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + 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(), 4) + def test_mode(self): params = {'mode': InterfaceModeChoices.MODE_ACCESS} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e17f94682..9cce21a21 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2204,6 +2204,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], @@ -2225,6 +2227,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'half', 'mgmt_only': True, 'description': 'A front port', + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], @@ -2244,6 +2248,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'full', 'mgmt_only': True, 'description': 'New description', + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT, 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, 'untagged_vlan': vlans[0].pk, @@ -2252,10 +2258,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - f"device,name,type,vrf.pk", - f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}", + f"device,name,type,vrf.pk,poe_mode,poe_type", + f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", ) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index c4cb8b72f..e98750518 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,14 @@
    + + + + + + + + From 568f801846ca9550cbe070e2574c358bad3eaf08 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 13:33:19 -0400 Subject: [PATCH 106/113] Closes #7744: Add status field to Location --- docs/models/dcim/location.md | 3 +-- docs/release-notes/version-3.3.md | 5 ++++- netbox/dcim/api/serializers.py | 5 +++-- netbox/dcim/choices.py | 22 +++++++++++++++++++ netbox/dcim/filtersets.py | 6 ++++- netbox/dcim/forms/bulk_edit.py | 8 ++++++- netbox/dcim/forms/bulk_import.py | 6 ++++- netbox/dcim/forms/filtersets.py | 6 ++++- netbox/dcim/forms/models.py | 8 +++++-- .../dcim/migrations/0156_location_status.py | 18 +++++++++++++++ netbox/dcim/models/sites.py | 10 ++++++++- netbox/dcim/tables/sites.py | 7 +++--- netbox/dcim/tests/test_api.py | 13 ++++++----- netbox/dcim/tests/test_filtersets.py | 10 ++++++--- netbox/dcim/tests/test_views.py | 15 +++++++------ netbox/templates/dcim/location.html | 4 ++++ 16 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 netbox/dcim/migrations/0156_location_status.py diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 901a68acf..fb72c218d 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -2,5 +2,4 @@ Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. -Each location must have a name that is unique within its parent site and location, if any. - +Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 801e45b51..6b825bd45 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -18,6 +18,7 @@ * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses * [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster +* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location * [#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 @@ -36,7 +37,9 @@ * dcim.DeviceType * The `u_height` field has been changed from an integer to a decimal * dcim.Interface - * Added the option `poe_mode` and `poe_type` fields + * Added the optional `poe_mode` and `poe_type` fields +* dcim.Location + * Added required `status` field (default value: `active`) * dcim.Rack * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit * extras.CustomField diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f3d223d4c..8ac2aa738 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -151,6 +151,7 @@ class LocationSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) + status = ChoiceField(choices=LocationStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) @@ -158,8 +159,8 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 44ec3fb88..94c8b255f 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet): ] +# +# Locations +# + +class LocationStatusChoices(ChoiceSet): + key = 'Location.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_RETIRED = 'retired' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), + ] + + # # Racks # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7c2d02bb3..628bd58f6 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -216,10 +216,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label='Location (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=LocationStatusChoices, + null_value=None + ) class Meta: model = Location - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'status', 'description'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 88f043c32..b4ab226ae 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + status = forms.ChoiceField( + choices=add_blank_choice(LocationStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): model = Location fieldsets = ( - (None, ('site', 'parent', 'tenant', 'description')), + (None, ('site', 'parent', 'status', 'tenant', 'description')), ) nullable_fields = ('parent', 'tenant', 'description') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 292f58785..d6ec0f6f4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm): 'invalid_choice': 'Location not found.', } ) + status = CSVChoiceField( + choices=LocationStatusChoices, + help_text='Operational status' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') class RackRoleCSVForm(NetBoxModelCSVForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index bdef32ec3..d9bc79fb5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF model = Location fieldsets = ( (None, ('q', 'tag')), - ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF }, label=_('Parent') ) + status = MultipleChoiceField( + choices=LocationStatusChoices, + required=False + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index c58500198..7aa2a8584 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'tags', ) + widgets = { + 'status': StaticSelect(), + } class RackRoleForm(NetBoxModelForm): diff --git a/netbox/dcim/migrations/0156_location_status.py b/netbox/dcim/migrations/0156_location_status.py new file mode 100644 index 000000000..b20273755 --- /dev/null +++ b/netbox/dcim/migrations/0156_location_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0155_interface_poe_mode_type'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index d02bd0932..9b7ffdcf4 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -341,6 +341,11 @@ class Location(NestedGroupModel): null=True, db_index=True ) + status = models.CharField( + max_length=50, + choices=LocationStatusChoices, + default=LocationStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -367,7 +372,7 @@ class Location(NestedGroupModel): to='extras.ImageAttachment' ) - clone_fields = ['site', 'parent', 'tenant', 'description'] + clone_fields = ['site', 'parent', 'status', 'tenant', 'description'] class Meta: ordering = ['site', 'name'] @@ -409,6 +414,9 @@ class Location(NestedGroupModel): def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) + def get_status_color(self): + return LocationStatusChoices.colors.get(self.status) + def clean(self): super().clean() diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index fa3c73e12..83db99aec 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -126,6 +126,7 @@ class LocationTable(NetBoxTable): site = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = TenantColumn() rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', @@ -150,7 +151,7 @@ class LocationTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts', - 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description', 'slug', + 'contacts', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') + default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a61b44f91..436f43b6f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -197,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase): Site.objects.bulk_create(sites) parent_locations = ( - Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'), - Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'), + Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE), + Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE), ) - Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0]) + Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) cls.create_data = [ { @@ -211,18 +211,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'slug': 'test-location-4', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 5', 'slug': 'test-location-5', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 6', 'slug': 'test-location-6', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f7d4c4e0a..9df75f4c0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'), + Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'), + Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'), + Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'), ) for location in locations: location.save() @@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['location-1', 'location-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9cce21a21..748bf24c8 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -175,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') locations = ( - Location(name='Location 1', slug='location-1', site=site, tenant=tenant), - Location(name='Location 2', slug='location-2', site=site, tenant=tenant), - Location(name='Location 3', slug='location-3', site=site, tenant=tenant), + Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), ) for location in locations: location.save() @@ -188,16 +188,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, + 'status': LocationStatusChoices.STATUS_PLANNED, 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,tenant,name,slug,description", - "Site 1,Tenant 1,Location 4,location-4,Fourth location", - "Site 1,Tenant 1,Location 5,location-5,Fifth location", - "Site 1,Tenant 1,Location 6,location-6,Sixth location", + "site,tenant,name,slug,status,description", + "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", + "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", + "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", ) cls.bulk_edit_data = { diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index b2b2bc4cd..f0335036f 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -43,6 +43,10 @@ + + + + + + + + From ee2df5cc241792f76e962002fa9b81e82c264c70 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 16:10:48 -0400 Subject: [PATCH 108/113] Closes #9582: Enable assigning config contexts based on device location --- docs/models/extras/configcontext.md | 2 + docs/release-notes/version-3.3.md | 3 ++ netbox/extras/api/serializers.py | 16 +++++-- netbox/extras/api/views.py | 2 +- netbox/extras/filtersets.py | 13 ++++- netbox/extras/forms/filtersets.py | 9 +++- netbox/extras/forms/models.py | 21 ++++++-- .../0076_configcontext_locations.py | 19 ++++++++ netbox/extras/models/configcontexts.py | 12 +++-- netbox/extras/querysets.py | 5 +- netbox/extras/tables/tables.py | 5 +- netbox/extras/tests/test_filtersets.py | 30 +++++++++--- netbox/extras/tests/test_models.py | 48 +++++++++++-------- netbox/extras/views.py | 2 +- .../templates/extras/configcontext_edit.html | 37 -------------- 15 files changed, 138 insertions(+), 86 deletions(-) create mode 100644 netbox/extras/migrations/0076_configcontext_locations.py delete mode 100644 netbox/templates/extras/configcontext_edit.html diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index bb4a22e0d..08b5f4fd5 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o * Region * Site group * Site +* Location (devices only) * Device type (devices only) * Role * Platform +* Cluster type (VMs only) * Cluster group (VMs only) * Cluster (VMs only) * Tenant group diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 66cfd2e66..2a2d4f683 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -25,6 +25,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 * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields +* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location ### Other Changes @@ -45,6 +46,8 @@ * Added required `status` field (default value: `active`) * dcim.Rack * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit +* extras.ConfigContext + * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations * extras.CustomField * Added `group_name` and `ui_visibility` fields * ipam.IPAddress diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cb317d6c7..2060e3e86 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer, - NestedSiteSerializer, NestedSiteGroupSerializer, + NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, + NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -272,6 +272,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + locations = SerializedPKRelatedField( + queryset=Location.objects.all(), + serializer=NestedLocationSerializer, + required=False, + many=True + ) device_types = SerializedPKRelatedField( queryset=DeviceType.objects.all(), serializer=NestedDeviceTypeSerializer, @@ -331,8 +337,8 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', - 'tenants', 'tags', 'data', 'created', 'last_updated', + 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', + 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', ] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 688f3c7ab..82c68c86d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet): class ConfigContextViewSet(NetBoxModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer filterset_class = filtersets.ConfigContextFilterSet diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index b59e28018..cca197c73 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter @@ -255,6 +255,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): to_field_name='slug', label='Site (slug)', ) + location_id = django_filters.ModelMultipleChoiceFilter( + field_name='locations', + queryset=Location.objects.all(), + label='Location', + ) + location = django_filters.ModelMultipleChoiceFilter( + field_name='locations__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label='Location (slug)', + ) device_type_id = django_filters.ModelMultipleChoiceFilter( field_name='device_types', queryset=DeviceType.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index aaeb45dbe..56f48f96b 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -170,7 +170,7 @@ class TagFilterForm(FilterForm): class ConfigContextFilterForm(FilterForm): fieldsets = ( (None, ('q', 'tag_id')), - ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), ('Tenant', ('tenant_group_id', 'tenant_id')) @@ -190,6 +190,11 @@ class ConfigContextFilterForm(FilterForm): required=False, label=_('Sites') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Locations') + ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index ab423e2fb..1ef723e93 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -166,6 +166,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Site.objects.all(), required=False ) + locations = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False + ) device_types = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False @@ -202,15 +206,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Tag.objects.all(), required=False ) - data = JSONField( - label='' + data = JSONField() + + fieldsets = ( + ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')), + ('Assignment', ( + 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + )), ) class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations', + 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'tags', ) diff --git a/netbox/extras/migrations/0076_configcontext_locations.py b/netbox/extras/migrations/0076_configcontext_locations.py new file mode 100644 index 000000000..f9b3a664b --- /dev/null +++ b/netbox/extras/migrations/0076_configcontext_locations.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.5 on 2022-06-22 19:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0156_location_status'), + ('extras', '0075_customfield_ui_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='locations', + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 0dc5d57db..30fb07069 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.core.validators import ValidationError from django.db import models from django.urls import reverse @@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): related_name='+', blank=True ) + locations = models.ManyToManyField( + to='dcim.Location', + related_name='+', + blank=True + ) device_types = models.ManyToManyField( to='dcim.DeviceType', related_name='+', @@ -138,11 +141,10 @@ class ConfigContextModel(models.Model): def get_config_context(self): """ + Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs. Return the rendered configuration context for a device or VM. """ - - # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs - data = OrderedDict() + data = {} if not hasattr(self, 'config_context_data'): # The annotation is not available, so we fall back to manually querying for the config context objects diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 21727d3d4..2b97af0fb 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role - # Device type assignment is relevant only for Devices + # Device type and location assignment is relevant only for Devices device_type = getattr(obj, 'device_type', None) + location = getattr(obj, 'location', None) # Get assigned cluster, group, and type (if any) cluster = getattr(obj, 'cluster', None) @@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): Q(regions__in=regions) | Q(regions=None), Q(site_groups__in=sitegroups) | Q(site_groups=None), Q(sites=obj.site) | Q(sites=None), + Q(locations=location) | Q(locations=None), Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), @@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) if self.model._meta.model_name == 'device': + base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND) base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND) base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND) base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 540034696..2fa13f98a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -167,8 +167,9 @@ class ConfigContextTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( - 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms', - 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated', + 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index bdb8de9db..a88ed9418 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import Provider -from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Manufacturer, Platform, Rack, Region, Site, SiteGroup from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices from extras.filtersets import * from extras.models import * @@ -368,9 +368,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): regions = ( - Region(name='Test Region 1', slug='test-region-1'), - Region(name='Test Region 2', slug='test-region-2'), - Region(name='Test Region 3', slug='test-region-3'), + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), ) for r in regions: r.save() @@ -384,12 +384,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), @@ -460,6 +468,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): c.regions.set([regions[i]]) c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) + c.locations.set([locations[i]]) c.device_types.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) @@ -501,6 +510,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device_type(self): device_types = DeviceType.objects.all()[:2] params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 17138d42b..4929690e7 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -29,7 +29,8 @@ class ConfigContextTest(TestCase): self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') self.region = Region.objects.create(name="Region") self.sitegroup = SiteGroup.objects.create(name="Site Group") - self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup) + self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup) + self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site) self.platform = Platform.objects.create(name="Platform") self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) @@ -40,7 +41,8 @@ class ConfigContextTest(TestCase): name='Device 1', device_type=self.devicetype, device_role=self.devicerole, - site=self.site + site=self.site, + location=self.location ) def test_higher_weight_wins(self): @@ -144,15 +146,6 @@ class ConfigContextTest(TestCase): self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_device_relations(self): - - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={ - "site": 1 - } - ) - site_context.sites.add(self.site) region_context = ConfigContext.objects.create( name="region", weight=100, @@ -169,6 +162,22 @@ class ConfigContextTest(TestCase): } ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={ + "site": 1 + } + ) + site_context.sites.add(self.site) + location_context = ConfigContext.objects.create( + name="location", + weight=100, + data={ + "location": 1 + } + ) + location_context.locations.add(self.location) platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -205,6 +214,7 @@ class ConfigContextTest(TestCase): device = Device.objects.create( name="Device 2", site=self.site, + location=self.location, tenant=self.tenant, platform=self.platform, device_role=self.devicerole, @@ -220,13 +230,6 @@ class ConfigContextTest(TestCase): cluster_group = ClusterGroup.objects.create(name="Cluster Group") cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={"site": 1} - ) - site_context.sites.add(self.site) - region_context = ConfigContext.objects.create( name="region", weight=100, @@ -241,6 +244,13 @@ class ConfigContextTest(TestCase): ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={"site": 1} + ) + site_context.sites.add(self.site) + platform_context = ConfigContext.objects.create( name="platform", weight=100, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9825d10de..bb99536c3 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView): ('Regions', instance.regions.all), ('Site Groups', instance.site_groups.all), ('Sites', instance.sites.all), + ('Locations', instance.locations.all), ('Device Types', instance.device_types.all), ('Roles', instance.roles.all), ('Platforms', instance.platforms.all), @@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView): class ConfigContextEditView(generic.ObjectEditView): queryset = ConfigContext.objects.all() form = forms.ConfigContextForm - template_name = 'extras/configcontext_edit.html' class ConfigContextBulkEditView(generic.BulkEditView): diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html deleted file mode 100644 index 7b37a69c6..000000000 --- a/netbox/templates/extras/configcontext_edit.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Config Context
    -
    - {% render_field form.name %} - {% render_field form.weight %} - {% render_field form.description %} - {% render_field form.is_active %} -
    -
    -
    -
    Assignment
    -
    - {% render_field form.regions %} - {% render_field form.site_groups %} - {% render_field form.sites %} - {% render_field form.device_types %} - {% render_field form.roles %} - {% render_field form.platforms %} - {% render_field form.cluster_types %} - {% render_field form.cluster_groups %} - {% render_field form.clusters %} - {% render_field form.tenant_groups %} - {% render_field form.tenants %} - {% render_field form.tags %} -
    -
    -
    -
    Data
    -
    - {% render_field form.data %} -
    -
    -{% endblock %} From ae95daa2705c2b0e3125ba50dfe5c1c24d881818 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 17:01:07 -0400 Subject: [PATCH 109/113] Refactor source IP resolution logic --- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/api/authentication.py | 40 +++++++++++++---------------- netbox/users/api/serializers.py | 5 +++- netbox/users/forms.py | 5 ++-- netbox/users/models.py | 15 +++++------ netbox/utilities/request.py | 27 +++++++++++++++++++ 6 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 netbox/utilities/request.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6e2f28730..f9a229aef 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -21,10 +21,10 @@ * [#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 * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API token access by source IP * [#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 * [#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 access by source IP * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 2f86a1da2..ea66dc5a6 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,41 +1,37 @@ 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 from users.models import Token +from utilities.request import get_client_ip class TokenAuthentication(authentication.TokenAuthentication): """ - A custom authentication scheme which enforces Token expiration times. + A custom authentication scheme which enforces Token expiration times and source IP restrictions. """ model = Token def authenticate(self, request): - authenticationresult = super().authenticate(request) - if authenticationresult: - token_user, token = authenticationresult + result = super().authenticate(request) - # Verify source IP is allowed + if result: + token = result[1] + + # Enforce source IP restrictions (if any) set on the token 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.") + client_ip = get_client_ip(request) + if client_ip is None: + raise exceptions.AuthenticationFailed( + "Client IP address could not be determined for validation. Check that the HTTP server is " + "correctly configured to pass the required header(s)." + ) + if not token.validate_client_ip(client_ip): + raise exceptions.AuthenticationFailed( + f"Source IP {client_ip} is not permitted to authenticate using this token." + ) - 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 + return result def authenticate_credentials(self, key): model = self.get_model() diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index b48a14d5c..2a40e45ac 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -67,7 +67,10 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', 'allowed_ips') + 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 9720f92b7..8692eb050 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -101,11 +101,12 @@ class TokenForm(BootstrapMixin, forms.ModelForm): required=False, 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"', + 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"', ) class Meta: diff --git a/netbox/users/models.py b/netbox/users/models.py index 5372353c0..222b088d6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -223,7 +223,9 @@ class Token(models.Model): 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"', + verbose_name='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"', ) class Meta: @@ -249,20 +251,15 @@ class Token(models.Model): return False return True - def validate_client_ip(self, raw_ip_address): + def validate_client_ip(self, client_ip): """ - Checks that an IP address falls within the allowed IPs. + Validate the API client IP address against the source IP restrictions (if any) set on the token. """ 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): + if client_ip in ipaddress.ip_network(ip_network): return True return False diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py new file mode 100644 index 000000000..0fac59d38 --- /dev/null +++ b/netbox/utilities/request.py @@ -0,0 +1,27 @@ +import ipaddress + +__all__ = ( + 'get_client_ip', +) + + +def get_client_ip(request, additional_headers=()): + """ + Return the client (source) IP address of the given request. + """ + HTTP_HEADERS = ( + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED_FOR', + 'REMOTE_ADDR', + *additional_headers + ) + for header in HTTP_HEADERS: + if header in request.META: + client_ip = request.META[header].split(',')[0] + try: + return ipaddress.ip_address(client_ip) + except ValueError: + raise ValueError(f"Invalid IP address set for {header}: {client_ip}") + + # Could not determine the client IP address from request headers + return None From 1d8028243ca3bb28930c38368005a7e574b87286 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 21:10:18 -0400 Subject: [PATCH 110/113] Add token authentication tests --- netbox/netbox/tests/test_authentication.py | 67 +++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7fc12b4fd..6597684fb 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,3 +1,5 @@ +import datetime + from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType @@ -8,10 +10,73 @@ from netaddr import IPNetwork from rest_framework.test import APIClient from dcim.models import Site -from ipam.choices import PrefixStatusChoices from ipam.models import Prefix from users.models import ObjectPermission, Token from utilities.testing import TestCase +from utilities.testing.api import APITestCase + + +class TokenAuthenticationTestCase(APITestCase): + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_authentication(self): + url = reverse('dcim-api:site-list') + + # Request without a token should return a 403 + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # Valid token should return a 200 + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_expiration(self): + url = reverse('dcim-api:site-list') + + # Request without a non-expired token should succeed + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + # Request with an expired token should fail + token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + token.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_write_enabled(self): + url = reverse('dcim-api:site-list') + data = { + 'name': 'Site 1', + 'slug': 'site-1', + } + + # Request with a write-disabled token should fail + token = Token.objects.create(user=self.user, write_enabled=False) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + # Request with a write-enabled token should succeed + token.write_enabled = True + token.save() + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_allowed_ips(self): + url = reverse('dcim-api:site-list') + + # Request from a non-allowed client IP should fail + token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24']) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1') + self.assertEqual(response.status_code, 403) + + # Request with an expired token should fail + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1') + self.assertEqual(response.status_code, 200) class ExternalAuthenticationTestCase(TestCase): From 66f6723ada076fb2105d4f49f7e76b63537ccd92 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 21:51:43 -0400 Subject: [PATCH 111/113] Introduce IPNetworkSerializer to serialize allowed token IPs --- docs/release-notes/version-3.3.md | 3 ++- netbox/netbox/api/__init__.py | 3 ++- netbox/netbox/api/fields.py | 21 +++++++++++++++++++-- netbox/users/api/serializers.py | 3 ++- netbox/users/models.py | 6 ++---- netbox/utilities/request.py | 4 ++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f9a229aef..81125451e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -13,6 +13,8 @@ #### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) +#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -21,7 +23,6 @@ * [#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 * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster -* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API token access by source IP * [#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 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 1eaa7d1c4..231ab55e6 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,4 +1,4 @@ -from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from .fields import * from .routers import NetBoxRouter from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -7,6 +7,7 @@ __all__ = ( 'BulkOperationSerializer', 'ChoiceField', 'ContentTypeField', + 'IPNetworkSerializer', 'NetBoxRouter', 'SerializedPKRelatedField', 'ValidatedModelSerializer', diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index d73cbcac2..1f3c40dc2 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,12 +1,18 @@ from collections import OrderedDict -import pytz -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from netaddr import IPNetwork from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField, RelatedField +__all__ = ( + 'ChoiceField', + 'ContentTypeField', + 'IPNetworkSerializer', + 'SerializedPKRelatedField', +) + class ChoiceField(serializers.Field): """ @@ -104,6 +110,17 @@ class ContentTypeField(RelatedField): return f"{obj.app_label}.{obj.model}" +class IPNetworkSerializer(serializers.Serializer): + """ + Representation of an IP network value (e.g. 192.0.2.0/24). + """ + def to_representation(self, instance): + return str(instance) + + def to_internal_value(self, value): + return IPNetwork(value) + + class SerializedPKRelatedField(PrimaryKeyRelatedField): """ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 2a40e45ac..e5ed1bb34 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,7 +2,7 @@ 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, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer from users.models import ObjectPermission, Token from .nested_serializers import * @@ -64,6 +64,7 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() + allowed_ips = serializers.ListField(child=IPNetworkSerializer()) class Meta: model = Token diff --git a/netbox/users/models.py b/netbox/users/models.py index 222b088d6..704516c71 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,12 +4,12 @@ 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 netaddr import IPNetwork from ipam.fields import IPNetworkField from netbox.config import get_config @@ -17,8 +17,6 @@ from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * -import ipaddress - __all__ = ( 'ObjectPermission', 'Token', @@ -259,7 +257,7 @@ class Token(models.Model): return True for ip_network in self.allowed_ips: - if client_ip in ipaddress.ip_network(ip_network): + if client_ip in IPNetwork(ip_network): return True return False diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 0fac59d38..3b8e1edde 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -1,4 +1,4 @@ -import ipaddress +from netaddr import IPAddress __all__ = ( 'get_client_ip', @@ -19,7 +19,7 @@ def get_client_ip(request, additional_headers=()): if header in request.META: client_ip = request.META[header].split(',')[0] try: - return ipaddress.ip_address(client_ip) + return IPAddress(client_ip) except ValueError: raise ValueError(f"Invalid IP address set for {header}: {client_ip}") From 09be00d9b3c6d59e2bab814bccaebf5516e2bf3e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 08:09:39 -0400 Subject: [PATCH 112/113] Allowed IPs should be optional on Token --- netbox/users/api/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index e5ed1bb34..177cce39c 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -64,7 +64,12 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() - allowed_ips = serializers.ListField(child=IPNetworkSerializer()) + allowed_ips = serializers.ListField( + child=IPNetworkSerializer(), + required=False, + allow_empty=True, + default=[] + ) class Meta: model = Token From eb20f5765d36b3a5ada0219057a6f12dc78f24bb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 08:12:36 -0400 Subject: [PATCH 113/113] Update token model docs --- docs/models/users/token.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/users/token.md b/docs/models/users/token.md index d98b51369..367444477 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -9,4 +9,4 @@ 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. +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.
    Site + {{ object.site|linkify|placeholder }} +
    Cluster {% if object.cluster.group %} {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster|linkify }} + {{ object.cluster|linkify|placeholder }}
    {{ device.serial|placeholder }}
    {{ message|markdown }} {% if field.to_field_name %} {{ field.to_field_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 32e586d3a..6d23c81aa 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -37,7 +37,7 @@ {% elif field.required %} Not defined {% else %} - + {{ ''|placeholder }} {% endif %}
    Power Port{{ object.power_port }}{{ object.power_port|linkify|placeholder }}
    Feed LegDescription {{ object.description|placeholder }}
    PoE Mode{{ object.get_poe_mode_display|placeholder }}
    PoE Mode{{ object.get_poe_type_display|placeholder }}
    802.1Q Mode {{ object.get_mode_display|placeholder }}Parent {{ object.parent|linkify|placeholder }}
    Status{% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant From f67aa2677cf8728081ee3676e712bfe105956d28 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 15:09:50 -0400 Subject: [PATCH 107/113] Closes #7120: Add termination_date field to Circuit --- docs/models/circuits/circuit.md | 2 +- docs/release-notes/version-3.3.md | 3 +++ netbox/circuits/api/serializers.py | 6 +++--- netbox/circuits/filtersets.py | 2 +- netbox/circuits/forms/bulk_edit.py | 14 ++++++++++++-- netbox/circuits/forms/bulk_import.py | 3 ++- netbox/circuits/forms/filtersets.py | 12 ++++++++++-- netbox/circuits/forms/models.py | 8 +++++--- .../0036_circuit_termination_date.py | 18 ++++++++++++++++++ netbox/circuits/models/circuits.py | 9 +++++++-- netbox/circuits/tables/circuits.py | 2 +- netbox/circuits/tests/test_filtersets.py | 16 ++++++++++------ netbox/circuits/tests/test_views.py | 1 + netbox/templates/circuits/circuit.html | 4 ++++ 14 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 netbox/circuits/migrations/0036_circuit_termination_date.py diff --git a/docs/models/circuits/circuit.md b/docs/models/circuits/circuit.md index 9421f94fb..3aaa4e99f 100644 --- a/docs/models/circuits/circuit.md +++ b/docs/models/circuits/circuit.md @@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses: * Deprovisioning * Decommissioned -Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants. +Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants. !!! note NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6b825bd45..66cfd2e66 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -18,6 +18,7 @@ * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses * [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations * [#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 * [#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 @@ -32,6 +33,8 @@ ### REST API Changes +* circuits.Circuit + * Added optional `termination_date` field * 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 19570f067..2bb3cd266 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -92,9 +92,9 @@ class CircuitSerializer(NetBoxModelSerializer): class Meta: model = Circuit fields = [ - 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', - 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', + 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index b7fa100a8..67a0d1b02 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class Meta: model = Circuit - fields = ['id', 'cid', 'description', 'install_date', 'commit_rate'] + fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 6e283219a..b6ba42afb 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect, ) @@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) + install_date = forms.DateField( + required=False, + widget=DatePicker() + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker() + ) commit_rate = forms.IntegerField( required=False, label='Commit rate (Kbps)' @@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): model = Circuit fieldsets = ( - (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')), + ('Circuit', ('provider', 'type', 'status', 'description')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), + ('Tenancy', ('tenant',)), ) nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 6da79f75c..cc2d0409a 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm): class Meta: model = Circuit fields = [ - 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', + 'description', 'comments', ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 46d3824bb..29410ffdf 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField +from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField __all__ = ( 'CircuitFilterForm', @@ -84,7 +84,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi fieldsets = ( (None, ('q', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), - ('Attributes', ('type_id', 'status', 'commit_rate')), + ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, label=_('Site') ) + install_date = forms.DateField( + required=False, + widget=DatePicker + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker + ) commit_rate = forms.IntegerField( required=False, min_value=0, diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 8fd5fb92d..907c39586 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Circuit fields = [ - 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', 'tags', + 'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description', + 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { 'cid': "Unique circuit ID", @@ -110,6 +111,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm): widgets = { 'status': StaticSelect(), 'install_date': DatePicker(), + 'termination_date': DatePicker(), 'commit_rate': SelectSpeedWidget(), } diff --git a/netbox/circuits/migrations/0036_circuit_termination_date.py b/netbox/circuits/migrations/0036_circuit_termination_date.py new file mode 100644 index 000000000..0a8adfbe6 --- /dev/null +++ b/netbox/circuits/migrations/0036_circuit_termination_date.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0035_provider_asns'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='termination_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 02ba5209d..5df6f1b85 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -78,7 +78,12 @@ class Circuit(NetBoxModel): install_date = models.DateField( blank=True, null=True, - verbose_name='Date installed' + verbose_name='Installed' + ) + termination_date = models.DateField( + blank=True, + null=True, + verbose_name='Terminates' ) commit_rate = models.PositiveIntegerField( blank=True, @@ -119,7 +124,7 @@ class Circuit(NetBoxModel): ) clone_fields = [ - 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', + 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description', ] class Meta: diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 40f8918ae..8b59700ee 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -70,7 +70,7 @@ class CircuitTable(NetBoxTable): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 205236712..28e0a3fe3 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), - Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), + Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), ) Circuit.objects.bulk_create(circuits) @@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'install_date': ['2020-01-01', '2020-01-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_termination_date(self): + params = {'termination_date': ['2021-01-01', '2021-01-02']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_commit_rate(self): params = {'commit_rate': ['1000', '2000']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 17c846c86..f60275ff3 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, 'tenant': None, 'install_date': datetime.date(2020, 1, 1), + 'termination_date': datetime.date(2021, 1, 1), 'commit_rate': 1000, 'description': 'A new circuit', 'comments': 'Some comments', diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 881b6cca6..a4c41f871 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -45,6 +45,10 @@ Install Date {{ object.install_date|annotated_date|placeholder }}
    Termination Date{{ object.termination_date|annotated_date|placeholder }}
    Commit Rate {{ object.commit_rate|humanize_speed|placeholder }}