From 2339fe22ae846ef24c1472ce13435f407b33da41 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Aug 2020 11:06:36 -0400 Subject: [PATCH 001/291] Closes #4941: commit argument is now required argument in a custom script's run() method --- docs/release-notes/index.md | 2 +- docs/release-notes/version-2.10.md | 7 +++++++ netbox/extras/scripts.py | 15 +-------------- 3 files changed, 9 insertions(+), 15 deletions(-) create mode 100644 docs/release-notes/version-2.10.md diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index f314c5371..8990f83e0 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.9.md \ No newline at end of file +version-2.10.md \ No newline at end of file diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md new file mode 100644 index 000000000..eb25d083b --- /dev/null +++ b/docs/release-notes/version-2.10.md @@ -0,0 +1,7 @@ +# NetBox v2.10 + +## v2.10-beta1 (FUTURE) + +### Other Changes + +* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 9d5380655..074cb82c5 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -428,24 +428,11 @@ def run_script(data, request, commit=True, *args, **kwargs): # Add the current request as a property of the script script.request = request - # TODO: Drop backward-compatibility for absent 'commit' argument in v2.10 - # Determine whether the script accepts a 'commit' argument (this was introduced in v2.7.8) - kwargs = { - 'data': data - } - if 'commit' in inspect.signature(script.run).parameters: - kwargs['commit'] = commit - else: - warnings.warn( - f"The run() method of script {script} should support a 'commit' argument. This will be required beginning " - f"with NetBox v2.10." - ) - with change_logging(request): try: with transaction.atomic(): - script.output = script.run(**kwargs) + script.output = script.run(data=data, commit=commit) job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED) if not commit: From d1071b79e3a1a4ce2d8ba8c0a4eba37f24f3e626 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Aug 2020 11:16:30 -0400 Subject: [PATCH 002/291] Closes #4360: Drop support for the Django template language in export templates --- docs/release-notes/version-2.10.md | 5 +++++ netbox/extras/admin.py | 7 +------ netbox/extras/api/serializers.py | 9 +-------- netbox/extras/filters.py | 2 +- ...8_exporttemplate_remove_template_language.py | 17 +++++++++++++++++ netbox/extras/models/models.py | 16 +--------------- netbox/extras/tests/test_filters.py | 10 +++------- 7 files changed, 29 insertions(+), 37 deletions(-) create mode 100644 netbox/extras/migrations/0048_exporttemplate_remove_template_language.py diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index eb25d083b..d289c7a05 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -4,4 +4,9 @@ ### Other Changes +* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates * [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method + +### REST API Changes + +* extras.ExportTemplate: The `template_language` field has been removed diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index a198d03d5..de6a9b470 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -198,11 +198,6 @@ class ExportTemplateForm(forms.ModelForm): class Meta: model = ExportTemplate exclude = [] - help_texts = { - 'template_language': "Warning: Support for Django templating will be dropped in NetBox " - "v2.10. Jinja2 is strongly " - "recommended." - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -219,7 +214,7 @@ class ExportTemplateAdmin(admin.ModelAdmin): 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension') }), ('Content', { - 'fields': ('template_language', 'template_code'), + 'fields': ('template_code',), 'classes': ('monospace',) }) ) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index aa8f6ba69..a186f772f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -71,17 +71,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer): content_type = ContentTypeField( queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), ) - template_language = ChoiceField( - choices=TemplateLanguageChoices, - default=TemplateLanguageChoices.LANGUAGE_JINJA2 - ) class Meta: model = ExportTemplate - fields = [ - 'id', 'url', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type', - 'file_extension', - ] + fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension'] # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index e8962da01..288be10e2 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -101,7 +101,7 @@ class ExportTemplateFilterSet(BaseFilterSet): class Meta: model = ExportTemplate - fields = ['id', 'content_type', 'name', 'template_language'] + fields = ['id', 'content_type', 'name'] class TagFilterSet(BaseFilterSet): diff --git a/netbox/extras/migrations/0048_exporttemplate_remove_template_language.py b/netbox/extras/migrations/0048_exporttemplate_remove_template_language.py new file mode 100644 index 000000000..68ff8f5ab --- /dev/null +++ b/netbox/extras/migrations/0048_exporttemplate_remove_template_language.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-08-21 15:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0047_tag_ordering'), + ] + + operations = [ + migrations.RemoveField( + model_name='exporttemplate', + name='template_language', + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e57caf091..40ba64a67 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -283,11 +283,6 @@ class ExportTemplate(models.Model): max_length=200, blank=True ) - template_language = models.CharField( - max_length=50, - choices=TemplateLanguageChoices, - default=TemplateLanguageChoices.LANGUAGE_JINJA2 - ) template_code = models.TextField( help_text='The list of objects being exported is passed as a context variable named queryset.' ) @@ -321,16 +316,7 @@ class ExportTemplate(models.Model): context = { 'queryset': queryset } - - if self.template_language == TemplateLanguageChoices.LANGUAGE_DJANGO: - template = Template(self.template_code) - output = template.render(Context(context)) - - elif self.template_language == TemplateLanguageChoices.LANGUAGE_JINJA2: - output = render_jinja2(self.template_code, context) - - else: - return None + output = render_jinja2(self.template_code, context) # Replace CRLF-style line terminators output = output.replace('\r\n', '\n') diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 72db138e2..deb41d628 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -55,9 +55,9 @@ class ExportTemplateTestCase(TestCase): content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) export_templates = ( - ExportTemplate(name='Export Template 1', content_type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, template_code='TESTING'), - ExportTemplate(name='Export Template 2', content_type=content_types[1], template_language=TemplateLanguageChoices.LANGUAGE_JINJA2, template_code='TESTING'), - ExportTemplate(name='Export Template 3', content_type=content_types[2], template_language=TemplateLanguageChoices.LANGUAGE_JINJA2, template_code='TESTING'), + ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING'), + ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING'), + ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), ) ExportTemplate.objects.bulk_create(export_templates) @@ -73,10 +73,6 @@ class ExportTemplateTestCase(TestCase): params = {'content_type': ContentType.objects.get(model='site').pk} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_template_language(self): - params = {'template_language': TemplateLanguageChoices.LANGUAGE_JINJA2} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - class ConfigContextTestCase(TestCase): queryset = ConfigContext.objects.all() From ee34e289864b42e07f008aab0cdfd4a538c3adb6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Aug 2020 11:18:57 -0400 Subject: [PATCH 003/291] Drop backward compatibility for REMOTE_AUTH_DEFAULT_PERMISSIONS defined as a list --- netbox/netbox/settings.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index fd50f6b5a..d49e41b76 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -132,17 +132,6 @@ if RELEASE_CHECK_URL: if RELEASE_CHECK_TIMEOUT < 3600: raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") -# TODO: Remove in v2.10 -# Backward compatibility for REMOTE_AUTH_DEFAULT_PERMISSIONS -if type(REMOTE_AUTH_DEFAULT_PERMISSIONS) is not dict: - try: - REMOTE_AUTH_DEFAULT_PERMISSIONS = {perm: None for perm in REMOTE_AUTH_DEFAULT_PERMISSIONS} - warnings.warn( - "REMOTE_AUTH_DEFAULT_PERMISSIONS should be a dictionary. Backward compatibility will be removed in v2.10." - ) - except TypeError: - raise ImproperlyConfigured("REMOTE_AUTH_DEFAULT_PERMISSIONS must be a dictionary.") - # # Database From ec66e1a5c08dac7b4e039f0e756f3621500fc2c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Aug 2020 11:57:46 -0400 Subject: [PATCH 004/291] Closes #4349: Drop support for embedded graphs --- docs/additional-features/graphs.md | 30 --------- docs/release-notes/version-2.10.md | 3 + mkdocs.yml | 1 - netbox/circuits/api/views.py | 15 ----- netbox/circuits/models.py | 2 +- netbox/circuits/tests/test_api.py | 24 ------- netbox/circuits/views.py | 5 +- netbox/dcim/api/views.py | 33 ---------- netbox/dcim/models/device_components.py | 2 +- netbox/dcim/models/devices.py | 2 +- netbox/dcim/models/sites.py | 2 +- netbox/dcim/tests/test_api.py | 63 ------------------- netbox/dcim/views.py | 5 -- netbox/extras/admin.py | 41 +----------- netbox/extras/api/nested_serializers.py | 9 --- netbox/extras/api/serializers.py | 39 +----------- netbox/extras/api/urls.py | 3 - netbox/extras/api/views.py | 13 +--- netbox/extras/choices.py | 15 ----- netbox/extras/constants.py | 1 - netbox/extras/filters.py | 10 +-- netbox/extras/migrations/0049_remove_graph.py | 16 +++++ netbox/extras/models/__init__.py | 3 +- netbox/extras/models/models.py | 63 ------------------- netbox/extras/tests/test_api.py | 35 +---------- netbox/extras/tests/test_filters.py | 39 +----------- netbox/extras/tests/test_models.py | 45 +------------ netbox/project-static/js/graphs.js | 26 -------- netbox/templates/circuits/provider.html | 10 --- netbox/templates/dcim/device.html | 8 --- netbox/templates/dcim/inc/interface.html | 7 --- netbox/templates/dcim/site.html | 11 ---- .../virtualization/inc/vminterface.html | 5 -- netbox/virtualization/api/views.py | 15 ----- netbox/virtualization/models.py | 2 +- netbox/virtualization/tests/test_api.py | 25 -------- 36 files changed, 33 insertions(+), 595 deletions(-) delete mode 100644 docs/additional-features/graphs.md create mode 100644 netbox/extras/migrations/0049_remove_graph.py delete mode 100644 netbox/project-static/js/graphs.js diff --git a/docs/additional-features/graphs.md b/docs/additional-features/graphs.md deleted file mode 100644 index e3551b91f..000000000 --- a/docs/additional-features/graphs.md +++ /dev/null @@ -1,30 +0,0 @@ -# Graphs - -!!! warning - Native support for embedded graphs is due to be removed in NetBox v2.10. It will likely be superseded by a plugin providing similar functionality. - -NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters: - -* **Type:** Site, device, provider, or interface. This determines in which view the graph will be displayed. -* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name. -* **Name:** The title to display above the graph. -* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. -* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. - -Graph names and links can be rendered using Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/). - -## Examples - -You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this: - -``` -https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m -``` - -You can define several graphs to provide multiple contexts when viewing an object. For example: - -``` -https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m -https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h -https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m -``` diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index d289c7a05..bc7c1a1a1 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -2,8 +2,11 @@ ## v2.10-beta1 (FUTURE) +**NOTE:** This release completely removes support for embedded graphs. + ### Other Changes +* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs * [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates * [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method diff --git a/mkdocs.yml b/mkdocs.yml index a94aa3cc4..bd8fc780d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,7 +49,6 @@ nav: - Custom Links: 'additional-features/custom-links.md' - Custom Scripts: 'additional-features/custom-scripts.md' - Export Templates: 'additional-features/export-templates.md' - - Graphs: 'additional-features/graphs.md' - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 746ee02f6..cd73a614d 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,14 +1,9 @@ from django.db.models import Count, Prefetch -from django.shortcuts import get_object_or_404 -from rest_framework.decorators import action -from rest_framework.response import Response from rest_framework.routers import APIRootView from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit -from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet -from extras.models import Graph from utilities.api import ModelViewSet from . import serializers @@ -32,16 +27,6 @@ class ProviderViewSet(CustomFieldModelViewSet): serializer_class = serializers.ProviderSerializer filterset_class = filters.ProviderFilterSet - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular provider. - """ - provider = get_object_or_404(self.queryset, pk=pk) - queryset = Graph.objects.restrict(request.user).filter(type__model='provider') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider}) - return Response(serializer.data) - # # Circuit Types diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index cdec41d1f..14f555cc6 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -22,7 +22,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index fa264aaa2..8a6289401 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,11 +1,8 @@ -from django.contrib.contenttypes.models import ContentType -from django.test import override_settings from django.urls import reverse from circuits.choices import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site -from extras.models import Graph from utilities.testing import APITestCase, APIViewTestCases @@ -46,27 +43,6 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): ) Provider.objects.bulk_create(providers) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_get_provider_graphs(self): - """ - Test retrieval of Graphs assigned to Providers. - """ - provider = self.model.objects.first() - ct = ContentType.objects.get(app_label='circuits', model='provider') - graphs = ( - Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'), - Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'), - Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'), - ) - Graph.objects.bulk_create(graphs) - - self.add_permissions('circuits.view_provider') - url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data), 3) - self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1') - class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 4d02ef011..e5da5100f 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,11 +1,10 @@ from django.conf import settings from django.contrib import messages from django.db import transaction -from django.db.models import Count, Prefetch +from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django_tables2 import RequestConfig -from extras.models import Graph from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( @@ -38,7 +37,6 @@ class ProviderView(ObjectView): ).prefetch_related( 'type', 'tenant', 'terminations__site' ).annotate_sites() - show_graphs = Graph.objects.filter(type__model='provider').exists() circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') @@ -52,7 +50,6 @@ class ProviderView(ObjectView): return render(request, 'circuits/provider.html', { 'provider': provider, 'circuits_table': circuits_table, - 'show_graphs': show_graphs, }) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f5b37021d..0583d4e56 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -23,9 +23,7 @@ from dcim.models import ( PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) -from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet -from extras.models import Graph from ipam.models import Prefix, VLAN from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, @@ -113,16 +111,6 @@ class SiteViewSet(CustomFieldModelViewSet): serializer_class = serializers.SiteSerializer filterset_class = filters.SiteFilterSet - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular site. - """ - site = get_object_or_404(self.queryset, pk=pk) - queryset = Graph.objects.restrict(request.user).filter(type__model='site') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site}) - return Response(serializer.data) - # # Rack groups @@ -363,17 +351,6 @@ class DeviceViewSet(CustomFieldModelViewSet): return serializers.DeviceWithConfigContextSerializer - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular Device. - """ - device = get_object_or_404(self.queryset, pk=pk) - queryset = Graph.objects.restrict(request.user).filter(type__model='device') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device}) - - return Response(serializer.data) - @swagger_auto_schema( manual_parameters=[ Parameter( @@ -527,16 +504,6 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilterSet - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular interface. - """ - interface = get_object_or_404(self.queryset, pk=pk) - queryset = Graph.objects.restrict(request.user).filter(type__model='interface') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) - return Response(serializer.data) - class FrontPortViewSet(CableTraceMixin, ModelViewSet): queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 92b0605e9..9bd7cdc8b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -582,7 +582,7 @@ class BaseInterface(models.Model): abstract = True -@extras_features('graphs', 'export_templates', 'webhooks') +@extras_features('export_templates', 'webhooks') class Interface(CableTermination, ComponentModel, BaseInterface): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4189e0446..8bb56101e 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -450,7 +450,7 @@ class Platform(ChangeLoggedModel): ) -@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index daf7055db..1ea083367 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -91,7 +91,7 @@ class Region(MPTTModel, ChangeLoggedModel): # Sites # -@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Site(ChangeLoggedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c3ffecdff..22085fdbd 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,6 +1,4 @@ from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType -from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -14,7 +12,6 @@ from dcim.models import ( Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) from ipam.models import VLAN -from extras.models import Graph from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterType @@ -132,26 +129,6 @@ class SiteTest(APIViewTestCases.APIViewTestCase): }, ] - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_get_site_graphs(self): - """ - Test retrieval of Graphs assigned to Sites. - """ - ct = ContentType.objects.get_for_model(Site) - graphs = ( - Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1'), - Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2'), - Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3'), - ) - Graph.objects.bulk_create(graphs) - - self.add_permissions('dcim.view_site') - url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data), 3) - self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?site=site-1&foo=1') - class RackGroupTest(APIViewTestCases.APIViewTestCase): model = RackGroup @@ -902,26 +879,6 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): }, ] - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_get_device_graphs(self): - """ - Test retrieval of Graphs assigned to Devices. - """ - ct = ContentType.objects.get_for_model(Device) - graphs = ( - Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'), - Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'), - Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'), - ) - Graph.objects.bulk_create(graphs) - - self.add_permissions('dcim.view_device') - url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data), 3) - self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Device 1&foo=1') - def test_config_context_included_by_default_in_list_view(self): """ Check that config context data is included by default in the devices list. @@ -1159,26 +1116,6 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, ] - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_get_interface_graphs(self): - """ - Test retrieval of Graphs assigned to Devices. - """ - ct = ContentType.objects.get_for_model(Interface) - graphs = ( - Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'), - Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'), - Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'), - ) - Graph.objects.bulk_create(graphs) - - self.add_permissions('dcim.view_interface') - url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data), 3) - self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') - class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = FrontPort diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c016f6e54..c3cfa105f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,7 +14,6 @@ from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit -from extras.models import Graph from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable @@ -172,13 +171,11 @@ class SiteView(ObjectView): rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate( rack_count=Count('racks') ) - show_graphs = Graph.objects.filter(type__model='site').exists() return render(request, 'dcim/site.html', { 'site': site, 'stats': stats, 'rack_groups': rack_groups, - 'show_graphs': show_graphs, }) @@ -1082,8 +1079,6 @@ class DeviceView(ObjectView): 'secrets': secrets, 'vc_members': vc_members, 'related_devices': related_devices, - 'show_graphs': Graph.objects.filter(type__model='device').exists(), - 'show_interface_graphs': Graph.objects.filter(type__model='interface').exists(), }) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index de6a9b470..5c95025cd 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,7 +2,7 @@ from django import forms from django.contrib import admin from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook +from .models import CustomField, CustomFieldChoice, CustomLink, ExportTemplate, JobResult, Webhook def order_content_types(field): @@ -150,45 +150,6 @@ class CustomLinkAdmin(admin.ModelAdmin): form = CustomLinkForm -# -# Graphs -# - -class GraphForm(forms.ModelForm): - - class Meta: - model = Graph - exclude = () - help_texts = { - 'template_language': "Jinja2 is strongly recommended for " - "new graphs." - } - widgets = { - 'source': forms.Textarea, - 'link': forms.Textarea, - } - - -@admin.register(Graph) -class GraphAdmin(admin.ModelAdmin): - fieldsets = ( - ('Graph', { - 'fields': ('type', 'name', 'weight') - }), - ('Templates', { - 'fields': ('template_language', 'source', 'link'), - 'classes': ('monospace',) - }) - ) - form = GraphForm - list_display = [ - 'name', 'type', 'weight', 'template_language', 'source', - ] - list_filter = [ - 'type', 'template_language', - ] - - # # Export templates # diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 198a5d2f8..95c980768 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -7,7 +7,6 @@ from utilities.api import ChoiceField, WritableNestedSerializer __all__ = [ 'NestedConfigContextSerializer', 'NestedExportTemplateSerializer', - 'NestedGraphSerializer', 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', 'NestedTagSerializer', @@ -30,14 +29,6 @@ class NestedExportTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name'] -class NestedGraphSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail') - - class Meta: - model = models.Graph - fields = ['id', 'url', 'name'] - - class NestedImageAttachmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a186f772f..1942f6f25 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -10,7 +10,7 @@ from dcim.api.nested_serializers import ( from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.choices import * from extras.models import ( - ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, + ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, ) from extras.utils import FeatureQuery from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer @@ -25,43 +25,6 @@ from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * -# -# Graphs -# - -class GraphSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail') - type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()), - ) - - class Meta: - model = Graph - fields = ['id', 'url', 'type', 'weight', 'name', 'template_language', 'source', 'link'] - - -class RenderedGraphSerializer(serializers.ModelSerializer): - embed_url = serializers.SerializerMethodField( - read_only=True - ) - embed_link = serializers.SerializerMethodField( - read_only=True - ) - type = ContentTypeField( - read_only=True - ) - - class Meta: - model = Graph - fields = ['id', 'type', 'weight', 'name', 'embed_url', 'embed_link'] - - def get_embed_url(self, obj): - return obj.embed_url(self.context['graphed_object']) - - def get_embed_link(self, obj): - return obj.embed_link(self.context['graphed_object']) - - # # Export templates # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 9c50c9a45..70e5bc9da 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -8,9 +8,6 @@ router.APIRootView = views.ExtrasRootView # Custom field choices router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') -# Graphs -router.register('graphs', views.GraphViewSet) - # Export templates router.register('export-templates', views.ExportTemplateViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 289a51c83..5fa26a0d7 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -15,7 +15,7 @@ from rq import Worker from extras import filters from extras.choices import JobResultStatusChoices from extras.models import ( - ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, + ConfigContext, CustomFieldChoice, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, ) from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script @@ -98,17 +98,6 @@ class CustomFieldModelViewSet(ModelViewSet): return super().get_queryset().prefetch_related('custom_field_values__field') -# -# Graphs -# - -class GraphViewSet(ModelViewSet): - metadata_class = ContentTypeMetadata - queryset = Graph.objects.all() - serializer_class = serializers.GraphSerializer - filterset_class = filters.GraphFilterSet - - # # Export templates # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index b14748135..7b4b1665a 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -79,21 +79,6 @@ class ObjectChangeActionChoices(ChoiceSet): ) -# -# ExportTemplates -# - -class TemplateLanguageChoices(ChoiceSet): - - LANGUAGE_JINJA2 = 'jinja2' - LANGUAGE_DJANGO = 'django' - - CHOICES = ( - (LANGUAGE_JINJA2, 'Jinja2'), - (LANGUAGE_DJANGO, 'Django (Legacy)'), - ) - - # # Log Levels for Reports and Scripts # diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index a506d5867..190e68c36 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -6,7 +6,6 @@ EXTRAS_FEATURES = [ 'custom_fields', 'custom_links', 'export_templates', - 'graphs', 'job_results', 'webhooks' ] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 288be10e2..73811c063 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag +from .models import ConfigContext, CustomField, ExportTemplate, ObjectChange, JobResult, Tag __all__ = ( @@ -16,7 +16,6 @@ __all__ = ( 'CustomFieldFilter', 'CustomFieldFilterSet', 'ExportTemplateFilterSet', - 'GraphFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', @@ -90,13 +89,6 @@ class CustomFieldFilterSet(django_filters.FilterSet): self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) -class GraphFilterSet(BaseFilterSet): - - class Meta: - model = Graph - fields = ['id', 'type', 'name', 'template_language'] - - class ExportTemplateFilterSet(BaseFilterSet): class Meta: diff --git a/netbox/extras/migrations/0049_remove_graph.py b/netbox/extras/migrations/0049_remove_graph.py new file mode 100644 index 000000000..c884c8f82 --- /dev/null +++ b/netbox/extras/migrations/0049_remove_graph.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1 on 2020-08-21 15:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0048_exporttemplate_remove_template_language'), + ] + + operations = [ + migrations.DeleteModel( + name='Graph', + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index e52058157..9437fe01f 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,7 +1,7 @@ from .change_logging import ChangeLoggedModel, ObjectChange from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue from .models import ( - ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, JobResult, Report, Script, + ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, Webhook, ) from .tags import Tag, TaggedItem @@ -16,7 +16,6 @@ __all__ = ( 'CustomFieldValue', 'CustomLink', 'ExportTemplate', - 'Graph', 'ImageAttachment', 'JobResult', 'ObjectChange', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 40ba64a67..8c88bb1da 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -203,69 +203,6 @@ class CustomLink(models.Model): return self.name -# -# Graphs -# - -class Graph(models.Model): - type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - limit_choices_to=FeatureQuery('graphs') - ) - weight = models.PositiveSmallIntegerField( - default=1000 - ) - name = models.CharField( - max_length=100, - verbose_name='Name' - ) - template_language = models.CharField( - max_length=50, - choices=TemplateLanguageChoices, - default=TemplateLanguageChoices.LANGUAGE_JINJA2 - ) - source = models.CharField( - max_length=500, - verbose_name='Source URL' - ) - link = models.URLField( - blank=True, - verbose_name='Link URL' - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - ordering = ('type', 'weight', 'name', 'pk') # (type, weight, name) may be non-unique - - def __str__(self): - return self.name - - def embed_url(self, obj): - context = {'obj': obj} - - if self.template_language == TemplateLanguageChoices.LANGUAGE_DJANGO: - template = Template(self.source) - return template.render(Context(context)) - - elif self.template_language == TemplateLanguageChoices.LANGUAGE_JINJA2: - return render_jinja2(self.source, context) - - def embed_link(self, obj): - if self.link is None: - return '' - - context = {'obj': obj} - - if self.template_language == TemplateLanguageChoices.LANGUAGE_DJANGO: - template = Template(self.link) - return template.render(Context(context)) - - elif self.template_language == TemplateLanguageChoices.LANGUAGE_JINJA2: - return render_jinja2(self.link, context) - - # # Export templates # diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index c768534a2..f66fea2ac 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -10,7 +10,7 @@ from rq import Worker from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site from extras.api.views import ReportViewSet, ScriptViewSet -from extras.models import ConfigContext, ExportTemplate, Graph, ImageAttachment, Tag +from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases @@ -29,39 +29,6 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class GraphTest(APIViewTestCases.APIViewTestCase): - model = Graph - brief_fields = ['id', 'name', 'url'] - create_data = [ - { - 'type': 'dcim.site', - 'name': 'Graph 4', - 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4', - }, - { - 'type': 'dcim.site', - 'name': 'Graph 5', - 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5', - }, - { - 'type': 'dcim.site', - 'name': 'Graph 6', - 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6', - }, - ] - - @classmethod - def setUpTestData(cls): - ct = ContentType.objects.get_for_model(Site) - - graphs = ( - Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'), - Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'), - Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'), - ) - Graph.objects.bulk_create(graphs) - - class ExportTemplateTest(APIViewTestCases.APIViewTestCase): model = ExportTemplate brief_fields = ['id', 'name', 'url'] diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index deb41d628..cc702f07b 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -2,49 +2,12 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.models import DeviceRole, Platform, Region, Site -from extras.choices import * from extras.filters import * -from extras.utils import FeatureQuery -from extras.models import ConfigContext, ExportTemplate, Graph, Tag +from extras.models import ConfigContext, ExportTemplate, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType -class GraphTestCase(TestCase): - queryset = Graph.objects.all() - filterset = GraphFilterSet - - @classmethod - def setUpTestData(cls): - - # Get the first three available types - content_types = ContentType.objects.filter(FeatureQuery('graphs').get_query())[:3] - - graphs = ( - Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'), - Graph(name='Graph 2', type=content_types[1], template_language=TemplateLanguageChoices.LANGUAGE_JINJA2, source='http://example.com/2'), - Graph(name='Graph 3', type=content_types[2], template_language=TemplateLanguageChoices.LANGUAGE_JINJA2, source='http://example.com/3'), - ) - Graph.objects.bulk_create(graphs) - - def test_id(self): - params = {'id': self.queryset.values_list('pk', flat=True)[:2]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_name(self): - params = {'name': ['Graph 1', 'Graph 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_type(self): - content_type = ContentType.objects.filter(FeatureQuery('graphs').get_query()).first() - params = {'type': content_type.pk} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - - def test_template_language(self): - params = {'template_language': TemplateLanguageChoices.LANGUAGE_JINJA2} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - class ExportTemplateTestCase(TestCase): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 22e6e1f8f..6a02872f8 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,49 +1,6 @@ -from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import Site -from extras.choices import TemplateLanguageChoices -from extras.models import Graph, Tag - - -class GraphTest(TestCase): - - def setUp(self): - - self.site = Site(name='Site 1', slug='site-1') - - def test_graph_render_django(self): - - # Using the pluralize filter as a sanity check (it's only available in Django) - TEMPLATE_TEXT = "{{ obj.name|lower }} thing{{ 2|pluralize }}" - RENDERED_TEXT = "site 1 things" - - graph = Graph( - type=ContentType.objects.get(app_label='dcim', model='site'), - name='Graph 1', - template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, - source=TEMPLATE_TEXT, - link=TEMPLATE_TEXT - ) - - self.assertEqual(graph.embed_url(self.site), RENDERED_TEXT) - self.assertEqual(graph.embed_link(self.site), RENDERED_TEXT) - - def test_graph_render_jinja2(self): - - TEMPLATE_TEXT = "{{ [obj.name, obj.slug]|join(',') }}" - RENDERED_TEXT = "Site 1,site-1" - - graph = Graph( - type=ContentType.objects.get(app_label='dcim', model='site'), - name='Graph 1', - template_language=TemplateLanguageChoices.LANGUAGE_JINJA2, - source=TEMPLATE_TEXT, - link=TEMPLATE_TEXT - ) - - self.assertEqual(graph.embed_url(self.site), RENDERED_TEXT) - self.assertEqual(graph.embed_link(self.site), RENDERED_TEXT) +from extras.models import Tag class TagTest(TestCase): diff --git a/netbox/project-static/js/graphs.js b/netbox/project-static/js/graphs.js deleted file mode 100644 index 4405c2903..000000000 --- a/netbox/project-static/js/graphs.js +++ /dev/null @@ -1,26 +0,0 @@ -$('#graphs_modal').on('show.bs.modal', function (event) { - var button = $(event.relatedTarget); - var obj = button.data('obj'); - var url = button.data('url'); - var modal_title = $(this).find('.modal-title'); - var modal_body = $(this).find('.modal-body'); - modal_title.text(obj); - modal_body.empty(); - $.ajax({ - url: url, - dataType: 'json', - success: function(json) { - $.each(json, function(i, graph) { - // Build in a 500ms delay per graph to avoid hammering the server - setTimeout(function() { - modal_body.append('

' + graph.name + '

'); - if (graph.embed_link) { - modal_body.append(''); - } else { - modal_body.append(''); - } - }, i*500); - }) - } - }); -}); diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 42c322ce2..e0598d872 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -30,11 +30,6 @@
{% plugin_buttons provider %} - {% if show_graphs %} - - {% endif %} {% if perms.circuits.add_provider %} {% clone_button provider %} {% endif %} @@ -138,14 +133,9 @@ {% plugin_right_page provider %}
-{% include 'inc/modal.html' with name='graphs' title='Graphs' %}
{% plugin_full_width_page provider %}
{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index e97893c30..09f6eab40 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -38,12 +38,6 @@
{% plugin_buttons device %} - {% if show_graphs %} - - {% endif %} {% if perms.dcim.change_device %}
-{% include 'inc/modal.html' with name='graphs' title='Graphs' %} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} @@ -1012,6 +1005,5 @@ $(".cable-toggle").click(function() { }); - {% endblock %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index ce66d9da2..a317dc937 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -138,13 +138,6 @@ {# Buttons #} - {% if show_interface_graphs %} - {% if iface.connected_endpoint %} - - {% endif %} - {% endif %} {% if perms.ipam.add_ipaddress %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index d6c21bf92..a0fbd59ec 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -35,12 +35,6 @@
{% plugin_buttons site %} - {% if show_graphs %} - - {% endif %} {% if perms.dcim.add_site %} {% clone_button site %} {% endif %} @@ -292,14 +286,9 @@ {% plugin_right_page site %}
-{% include 'inc/modal.html' with name='graphs' title='Graphs' %}
{% plugin_full_width_page site %}
{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html index 5410fba7a..0f672b729 100644 --- a/netbox/templates/virtualization/inc/vminterface.html +++ b/netbox/templates/virtualization/inc/vminterface.html @@ -38,11 +38,6 @@ {# Buttons #} - {% if show_interface_graphs %} - - {% endif %} {% if perms.ipam.add_ipaddress %}
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 1bf41c2b7..9d210459e 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,13 +1,8 @@ from django.db.models import Count -from django.shortcuts import get_object_or_404 -from rest_framework.decorators import action -from rest_framework.response import Response from rest_framework.routers import APIRootView from dcim.models import Device -from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet -from extras.models import Graph from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters @@ -91,13 +86,3 @@ class VMInterfaceViewSet(ModelViewSet): ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filters.VMInterfaceFilterSet - - @action(detail=True) - def graphs(self, request, pk): - """ - A convenience method for rendering graphs for a particular VM interface. - """ - vminterface = get_object_or_404(self.queryset, pk=pk) - queryset = Graph.objects.restrict(request.user).filter(type__model='vminterface') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': vminterface}) - return Response(serializer.data) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index f787aef0e..fb61c5b9e 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -381,7 +381,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Interfaces # -@extras_features('graphs', 'export_templates', 'webhooks') +@extras_features('export_templates', 'webhooks') class VMInterface(BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 6ddcdf2ef..28d4bbb99 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -1,10 +1,7 @@ -from django.contrib.contenttypes.models import ContentType -from django.test import override_settings from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices -from extras.models import Graph from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -244,25 +241,3 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'untagged_vlan': vlans[2].pk, }, ] - - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_get_vminterface_graphs(self): - """ - Test retrieval of Graphs assigned to VM interfaces. - """ - ct = ContentType.objects.get_for_model(VMInterface) - graphs = ( - Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'), - Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'), - Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'), - ) - Graph.objects.bulk_create(graphs) - - self.add_permissions('virtualization.view_vminterface') - url = reverse('virtualization-api:vminterface-graphs', kwargs={ - 'pk': VMInterface.objects.first().pk - }) - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data), 3) - self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') From 879166d939314bdc8745dbede3f8af67c61dd1dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Aug 2020 15:16:33 -0400 Subject: [PATCH 005/291] Initial work on reimplementing custom fields --- .../migrations/0020_custom_field_data.py | 23 +++++++++ .../dcim/migrations/0115_custom_field_data.py | 38 ++++++++++++++ .../0050_migrate_customfieldvalues.py | 50 +++++++++++++++++++ netbox/extras/models/customfields.py | 4 ++ .../ipam/migrations/0038_custom_field_data.py | 43 ++++++++++++++++ .../migrations/0010_custom_field_data.py | 18 +++++++ .../migrations/0010_custom_field_data.py | 18 +++++++ .../migrations/0018_custom_field_data.py | 23 +++++++++ 8 files changed, 217 insertions(+) create mode 100644 netbox/circuits/migrations/0020_custom_field_data.py create mode 100644 netbox/dcim/migrations/0115_custom_field_data.py create mode 100644 netbox/extras/migrations/0050_migrate_customfieldvalues.py create mode 100644 netbox/ipam/migrations/0038_custom_field_data.py create mode 100644 netbox/secrets/migrations/0010_custom_field_data.py create mode 100644 netbox/tenancy/migrations/0010_custom_field_data.py create mode 100644 netbox/virtualization/migrations/0018_custom_field_data.py diff --git a/netbox/circuits/migrations/0020_custom_field_data.py b/netbox/circuits/migrations/0020_custom_field_data.py new file mode 100644 index 000000000..c72b937db --- /dev/null +++ b/netbox/circuits/migrations/0020_custom_field_data.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-08-21 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0019_nullbooleanfield_to_booleanfield'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='provider', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/netbox/dcim/migrations/0115_custom_field_data.py b/netbox/dcim/migrations/0115_custom_field_data.py new file mode 100644 index 000000000..58c320902 --- /dev/null +++ b/netbox/dcim/migrations/0115_custom_field_data.py @@ -0,0 +1,38 @@ +# Generated by Django 3.1 on 2020-08-21 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0114_update_jsonfield'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='devicetype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='powerfeed', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='rack', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='site', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/netbox/extras/migrations/0050_migrate_customfieldvalues.py b/netbox/extras/migrations/0050_migrate_customfieldvalues.py new file mode 100644 index 000000000..9b2402267 --- /dev/null +++ b/netbox/extras/migrations/0050_migrate_customfieldvalues.py @@ -0,0 +1,50 @@ +from django.db import migrations + +from extras.choices import CustomFieldTypeChoices + + +def deserialize_value(field_type, value): + """ + Convert serialized values to JSON equivalents. + """ + if field_type in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_SELECT): + return int(value) + if field_type == CustomFieldTypeChoices.TYPE_BOOLEAN: + return bool(int(value)) + return value + + +def migrate_customfieldvalues(apps, schema_editor): + CustomFieldValue = apps.get_model('extras', 'CustomFieldValue') + + for cfv in CustomFieldValue.objects.prefetch_related('field').exclude(serialized_value=''): + model = apps.get_model(cfv.obj_type.app_label, cfv.obj_type.model) + + # Read and update custom field value for each instance + # TODO: This can be done more efficiently once .update() is supported for JSON fields + cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first() + try: + cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field.type, cfv.serialized_value) + except ValueError as e: + print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})') + raise e + model.objects.filter(pk=cfv.obj_id).update(**cf_data) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0020_custom_field_data'), + ('dcim', '0115_custom_field_data'), + ('extras', '0049_remove_graph'), + ('ipam', '0038_custom_field_data'), + ('secrets', '0010_custom_field_data'), + ('tenancy', '0010_custom_field_data'), + ('virtualization', '0018_custom_field_data'), + ] + + operations = [ + migrations.RunPython( + code=migrate_customfieldvalues + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index fd09971b6..1c86812e0 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -17,6 +17,10 @@ from extras.utils import FeatureQuery # class CustomFieldModel(models.Model): + custom_field_data = models.JSONField( + blank=True, + default=dict + ) class Meta: abstract = True diff --git a/netbox/ipam/migrations/0038_custom_field_data.py b/netbox/ipam/migrations/0038_custom_field_data.py new file mode 100644 index 000000000..c9d66975c --- /dev/null +++ b/netbox/ipam/migrations/0038_custom_field_data.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1 on 2020-08-21 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0037_ipaddress_assignment'), + ] + + operations = [ + migrations.AddField( + model_name='aggregate', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='ipaddress', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='prefix', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='service', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='vlan', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='vrf', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/netbox/secrets/migrations/0010_custom_field_data.py b/netbox/secrets/migrations/0010_custom_field_data.py new file mode 100644 index 000000000..33b5f06e1 --- /dev/null +++ b/netbox/secrets/migrations/0010_custom_field_data.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-08-21 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0009_secretrole_drop_users_groups'), + ] + + operations = [ + migrations.AddField( + model_name='secret', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/netbox/tenancy/migrations/0010_custom_field_data.py b/netbox/tenancy/migrations/0010_custom_field_data.py new file mode 100644 index 000000000..4d05a8272 --- /dev/null +++ b/netbox/tenancy/migrations/0010_custom_field_data.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-08-21 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0009_standardize_description'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/netbox/virtualization/migrations/0018_custom_field_data.py b/netbox/virtualization/migrations/0018_custom_field_data.py new file mode 100644 index 000000000..5c129e2ab --- /dev/null +++ b/netbox/virtualization/migrations/0018_custom_field_data.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-08-21 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0017_update_jsonfield'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='virtualmachine', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict), + ), + ] From 2276603ac386a0c7edfe8a7cb6ad856fd44b392f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Aug 2020 15:53:38 -0400 Subject: [PATCH 006/291] Drop CustomFieldValue --- docs/administration/netbox-shell.md | 8 +- netbox/circuits/models.py | 11 --- netbox/dcim/models/devices.py | 10 -- netbox/dcim/models/power.py | 6 -- netbox/dcim/models/racks.py | 5 - netbox/dcim/models/sites.py | 5 - netbox/extras/api/customfields.py | 11 +-- netbox/extras/api/views.py | 4 - netbox/extras/forms.py | 26 +----- .../0051_delete_customfieldvalue.py | 16 ++++ netbox/extras/models/__init__.py | 3 +- netbox/extras/models/customfields.py | 91 +++---------------- netbox/extras/querysets.py | 20 +--- netbox/extras/tests/test_changelog.py | 22 ++--- netbox/extras/tests/test_customfields.py | 60 ++++++------ netbox/ipam/models.py | 32 +------ netbox/secrets/models.py | 6 -- netbox/tenancy/models.py | 6 -- netbox/utilities/views.py | 49 ++-------- netbox/virtualization/models.py | 10 -- 20 files changed, 87 insertions(+), 314 deletions(-) create mode 100644 netbox/extras/migrations/0051_delete_customfieldvalue.py diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index 51a06156a..2a8d31494 100644 --- a/docs/administration/netbox-shell.md +++ b/docs/administration/netbox-shell.md @@ -185,7 +185,7 @@ To delete an object, simply call `delete()` on its instance. This will return a >>> vlan >>> vlan.delete() -(1, {'extras.CustomFieldValue': 0, 'ipam.VLAN': 1}) +(1, {'ipam.VLAN': 1}) ``` To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them. @@ -194,9 +194,9 @@ To delete multiple objects at once, call `delete()` on a filtered queryset. It's >>> Device.objects.filter(name__icontains='test').count() 27 >>> Device.objects.filter(name__icontains='test').delete() -(35, {'extras.CustomFieldValue': 0, 'dcim.DeviceBay': 0, 'secrets.Secret': 0, -'dcim.InterfaceConnection': 4, 'extras.ImageAttachment': 0, 'dcim.Device': 27, -'dcim.Interface': 4, 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0}) +(35, {'dcim.DeviceBay': 0, 'secrets.Secret': 0, 'dcim.InterfaceConnection': 4, +'extras.ImageAttachment': 0, 'dcim.Device': 27, 'dcim.Interface': 4, +'dcim.ConsolePort': 0, 'dcim.PowerPort': 0}) ``` !!! warning diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 14f555cc6..fbe568f18 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from taggit.managers import TaggableManager @@ -61,11 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -186,11 +180,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) objects = CircuitQuerySet.as_manager() tags = TaggableManager(through=TaggedItem) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8bb56101e..b10fd6b74 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -134,11 +134,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -584,11 +579,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index f760fea13..08ae194ae 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -144,11 +143,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3169272b4..e30481cd6 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -261,11 +261,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 1ea083367..0d33da806 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -183,11 +183,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5ef983977..a0238129b 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -8,7 +8,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.fields import CreateOnlyDefault from extras.choices import * -from extras.models import CustomField, CustomFieldChoice, CustomFieldValue +from extras.models import CustomField, CustomFieldChoice from utilities.api import ValidatedModelSerializer @@ -164,15 +164,8 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): instance.custom_fields[field.name] = value def _save_custom_fields(self, instance, custom_fields): - content_type = ContentType.objects.get_for_model(self.Meta.model) for field_name, value in custom_fields.items(): - custom_field = CustomField.objects.get(name=field_name) - CustomFieldValue.objects.update_or_create( - field=custom_field, - obj_type=content_type, - obj_id=instance.pk, - defaults={'serialized_value': custom_field.serialize_value(value)}, - ) + instance.custom_field_data[field_name] = value def create(self, validated_data): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 5fa26a0d7..5be8276b6 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -93,10 +93,6 @@ class CustomFieldModelViewSet(ModelViewSet): }) return context - def get_queryset(self): - # Prefetch custom field values - return super().get_queryset().prefetch_related('custom_field_values__field') - # # Export templates diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 90ec828c7..40c675c4d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -12,7 +12,7 @@ from utilities.forms import ( ) from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag +from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag # @@ -40,11 +40,7 @@ class CustomFieldModelForm(forms.ModelForm): """ # Retrieve initial CustomField values for the instance if self.instance.pk: - for cfv in CustomFieldValue.objects.filter( - obj_type=self.obj_type, - obj_id=self.instance.pk - ).prefetch_related('field'): - self.custom_field_values[cfv.field.name] = cfv.serialized_value + self.custom_field_values = self.instance.custom_field_data # Append form fields; assign initial values if modifying and existing object for cf in CustomField.objects.filter(obj_type=self.obj_type): @@ -64,23 +60,7 @@ class CustomFieldModelForm(forms.ModelForm): def _save_custom_fields(self): for field_name in self.custom_fields: - try: - cfv = CustomFieldValue.objects.prefetch_related('field').get( - field=self.fields[field_name].model, - obj_type=self.obj_type, - obj_id=self.instance.pk - ) - except CustomFieldValue.DoesNotExist: - # Skip this field if none exists already and its value is empty - if self.cleaned_data[field_name] in [None, '']: - continue - cfv = CustomFieldValue( - field=self.fields[field_name].model, - obj_type=self.obj_type, - obj_id=self.instance.pk - ) - cfv.value = self.cleaned_data[field_name] - cfv.save() + self.instance.custom_field_data[field_name[3:]] = self.cleaned_data[field_name] def save(self, commit=True): diff --git a/netbox/extras/migrations/0051_delete_customfieldvalue.py b/netbox/extras/migrations/0051_delete_customfieldvalue.py new file mode 100644 index 000000000..3369289a0 --- /dev/null +++ b/netbox/extras/migrations/0051_delete_customfieldvalue.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1 on 2020-08-21 19:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0050_migrate_customfieldvalues'), + ] + + operations = [ + migrations.DeleteModel( + name='CustomFieldValue', + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 9437fe01f..a4178b911 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,5 +1,5 @@ from .change_logging import ChangeLoggedModel, ObjectChange -from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue +from .customfields import CustomField, CustomFieldChoice, CustomFieldModel from .models import ( ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, Webhook, @@ -13,7 +13,6 @@ __all__ = ( 'CustomField', 'CustomFieldChoice', 'CustomFieldModel', - 'CustomFieldValue', 'CustomLink', 'ExportTemplate', 'ImageAttachment', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 1c86812e0..b0ea76cef 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,8 +1,6 @@ -from collections import OrderedDict from datetime import date from django import forms -from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models @@ -29,36 +27,12 @@ class CustomFieldModel(models.Model): self._cf = custom_fields super().__init__(*args, **kwargs) - def cache_custom_fields(self): - """ - Cache all custom field values for this instance - """ - self._cf = { - field.name: value for field, value in self.get_custom_fields().items() - } - @property def cf(self): """ - Name-based CustomFieldValue accessor for use in templates + Convenience wrapper for custom field data. """ - if self._cf is None: - self.cache_custom_fields() - return self._cf - - def get_custom_fields(self): - """ - Return a dictionary of custom fields for a single object in the form {: value}. - """ - fields = CustomField.objects.get_for_model(self) - - # If the object exists, populate its custom fields with values - if hasattr(self, 'pk'): - values = self.custom_field_values.all() - values_dict = {cfv.field_id: cfv.value for cfv in values} - return OrderedDict([(field, values_dict.get(field.pk)) for field in fields]) - else: - return OrderedDict([(field, None) for field in fields]) + return self.custom_field_data class CustomFieldManager(models.Manager): @@ -235,49 +209,6 @@ class CustomField(models.Model): return field -class CustomFieldValue(models.Model): - field = models.ForeignKey( - to='extras.CustomField', - on_delete=models.CASCADE, - related_name='values' - ) - obj_type = models.ForeignKey( - to=ContentType, - on_delete=models.PROTECT, - related_name='+' - ) - obj_id = models.PositiveIntegerField() - obj = GenericForeignKey( - ct_field='obj_type', - fk_field='obj_id' - ) - serialized_value = models.CharField( - max_length=255 - ) - - class Meta: - ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique - unique_together = ('field', 'obj_type', 'obj_id') - - def __str__(self): - return '{} {}'.format(self.obj, self.field) - - @property - def value(self): - return self.field.deserialize_value(self.serialized_value) - - @value.setter - def value(self, value): - self.serialized_value = self.field.serialize_value(value) - - def save(self, *args, **kwargs): - # Delete this object if it no longer has a value to store - if self.pk and self.value is None: - self.delete() - else: - super().save(*args, **kwargs) - - class CustomFieldChoice(models.Model): field = models.ForeignKey( to='extras.CustomField', @@ -304,11 +235,13 @@ class CustomFieldChoice(models.Model): if self.field.type != CustomFieldTypeChoices.TYPE_SELECT: raise ValidationError("Custom field choices can only be assigned to selection fields.") - def delete(self, using=None, keep_parents=False): - # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it - pk = self.pk - super().delete(using, keep_parents) - CustomFieldValue.objects.filter( - field__type=CustomFieldTypeChoices.TYPE_SELECT, - serialized_value=str(pk) - ).delete() + def delete(self, *args, **kwargs): + # TODO: Prevent deletion of CustomFieldChoices which are in use? + field_name = f'custom_field_data__{self.field.name}' + for ct in self.field.obj_type.all(): + model = ct.model_class() + for instance in model.objects.filter(**{field_name: self.pk}): + instance.custom_field_data.pop(self.field.name) + instance.save() + + super().delete(*args, **kwargs) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 9d9b55778..b7019897b 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,26 +1,8 @@ -from collections import OrderedDict - -from django.db.models import Q, QuerySet +from django.db.models import Q from utilities.querysets import RestrictedQuerySet -class CustomFieldQueryset: - """ - Annotate custom fields on objects within a QuerySet. - """ - def __init__(self, queryset, custom_fields): - self.queryset = queryset - self.model = queryset.model - self.custom_fields = custom_fields - - def __iter__(self): - for obj in self.queryset: - values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()} - obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields]) - yield obj - - class ConfigContextQuerySet(RestrictedQuerySet): def get_for_object(self, obj): diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 8c9d09564..5d1dee864 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -5,7 +5,7 @@ from rest_framework import status from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.choices import * -from extras.models import CustomField, CustomFieldValue, ObjectChange, Tag +from extras.models import CustomField, ObjectChange, Tag from utilities.testing import APITestCase from utilities.testing.utils import post_data from utilities.testing.views import ModelViewTestCase @@ -93,16 +93,14 @@ class ChangeLogViewTest(ModelViewTestCase): def test_delete_object(self): site = Site( name='Test Site 1', - slug='test-site-1' + slug='test-site-1', + custom_field_data={ + 'my_field': 'ABC' + } ) site.save() self.create_tags('Tag 1', 'Tag 2') site.tags.set('Tag 1', 'Tag 2') - CustomFieldValue.objects.create( - field=CustomField.objects.get(name='my_field'), - obj=site, - value='ABC' - ) request = { 'path': self._get_url('delete', instance=site), @@ -209,15 +207,13 @@ class ChangeLogAPITest(APITestCase): def test_delete_object(self): site = Site( name='Test Site 1', - slug='test-site-1' + slug='test-site-1', + custom_field_data={ + 'my_field': 'ABC' + } ) site.save() site.tags.set(*Tag.objects.all()[:2]) - CustomFieldValue.objects.create( - field=CustomField.objects.get(name='my_field'), - obj=site, - value='ABC' - ) self.assertEqual(ObjectChange.objects.count(), 0) self.add_permissions('dcim.delete_site') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index e543b63e3..74c0e7c3b 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -7,7 +7,7 @@ from rest_framework import status from dcim.forms import SiteCSVForm from dcim.models import Site from extras.choices import * -from extras.models import CustomField, CustomFieldValue, CustomFieldChoice +from extras.models import CustomField, CustomFieldChoice from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -46,18 +46,18 @@ class CustomFieldTest(TestCase): # Assign a value to the first Site site = Site.objects.first() - cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id) - cfv.value = data['field_value'] - cfv.save() + site.custom_field_data[cf.name] = data['field_value'] + site.save() # Retrieve the stored value - cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first() - self.assertEqual(cfv.value, data['field_value']) + site.refresh_from_db() + self.assertEqual(site.custom_field_data[cf.name], data['field_value']) # Delete the stored value - cfv.value = data['empty_value'] - cfv.save() - self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0) + site.custom_field_data.pop(cf.name) + site.save() + site.refresh_from_db() + self.assertIsNone(site.custom_field_data.get(cf.name)) # Delete the custom field cf.delete() @@ -81,18 +81,18 @@ class CustomFieldTest(TestCase): # Assign a value to the first Site site = Site.objects.first() - cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id) - cfv.value = cf.choices.first() - cfv.save() + site.custom_field_data[cf.name] = cf.choices.first().pk + site.save() # Retrieve the stored value - cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first() - self.assertEqual(str(cfv.value), 'Option A') + site.refresh_from_db() + self.assertEqual(site.custom_field_data[cf.name], 'Option A') # Delete the stored value - cfv.value = None - cfv.save() - self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0) + site.custom_field_data.pop(cf.name) + site.save() + site.refresh_from_db() + self.assertIsNone(site.custom_field_data.get(cf.name)) # Delete the custom field cf.delete() @@ -164,18 +164,15 @@ class CustomFieldAPITest(APITestCase): Site.objects.bulk_create(cls.sites) # Assign custom field values for site 2 - site2_cfvs = { - cls.cf_text: 'bar', - cls.cf_integer: 456, - cls.cf_boolean: True, - cls.cf_date: '2020-01-02', - cls.cf_url: 'http://example.com/2', - cls.cf_select: cls.cf_select_choice2.pk, + cls.sites[1].custom_field_data = { + cls.cf_text.name: 'bar', + cls.cf_integer.name: 456, + cls.cf_boolean.name: True, + cls.cf_date.name: '2020-01-02', + cls.cf_url.name: 'http://example.com/2', + cls.cf_select.name: cls.cf_select_choice2.pk, } - for field, value in site2_cfvs.items(): - cfv = CustomFieldValue(field=field, obj=cls.sites[1]) - cfv.value = value - cfv.save() + cls.sites[1].save() def test_get_single_object_without_custom_field_values(self): """ @@ -518,7 +515,7 @@ class CustomFieldImportTest(TestCase): # Validate data for site 1 custom_field_values = { - cf.name: value for cf, value in Site.objects.get(name='Site 1').get_custom_fields().items() + cf.name: value for cf, value in Site.objects.get(name='Site 1').custom_field_data } self.assertEqual(len(custom_field_values), 6) self.assertEqual(custom_field_values['text'], 'ABC') @@ -530,7 +527,7 @@ class CustomFieldImportTest(TestCase): # Validate data for site 2 custom_field_values = { - cf.name: value for cf, value in Site.objects.get(name='Site 2').get_custom_fields().items() + cf.name: value for cf, value in Site.objects.get(name='Site 2').custom_field_data } self.assertEqual(len(custom_field_values), 6) self.assertEqual(custom_field_values['text'], 'DEF') @@ -543,8 +540,7 @@ class CustomFieldImportTest(TestCase): # No CustomFieldValues should be created for site 3 obj_type = ContentType.objects.get_for_model(Site) site3 = Site.objects.get(name='Site 3') - self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists()) - self.assertEqual(CustomFieldValue.objects.count(), 12) # Sanity check + self.assertEqual(site3.custom_field_data, {}) def test_import_missing_required(self): """ diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 58dd96089..e5ae122ab 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,6 +1,6 @@ import netaddr from django.conf import settings -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -70,11 +70,6 @@ class VRF(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -178,11 +173,6 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -364,11 +354,6 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = PrefixQuerySet.as_manager() @@ -647,11 +632,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = IPAddressManager() @@ -928,11 +908,6 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -1043,11 +1018,6 @@ class Service(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 6209b5700..23a883103 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -6,7 +6,6 @@ from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import Group, User -from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -306,11 +305,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel): max_length=128, editable=False ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index cc3abf19a..5a6108e09 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey @@ -102,11 +101,6 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c7db2f649..51c7a26f9 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -26,8 +26,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME from django.views.generic import View from django_tables2 import RequestConfig -from extras.models import CustomField, CustomFieldValue, ExportTemplate -from extras.querysets import CustomFieldQueryset +from extras.models import CustomField, ExportTemplate from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields from utilities.permissions import get_permission_for_model, resolve_permission @@ -229,8 +228,8 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): headers = self.queryset.model.csv_headers.copy() # Add custom field headers, if any - if hasattr(self.queryset.model, 'get_custom_fields'): - for custom_field in self.queryset.model().get_custom_fields(): + if hasattr(self.queryset.model, 'custom_field_data'): + for custom_field in CustomField.objects.get_for_model(self.queryset.model): headers.append(custom_field.name) custom_fields.append(custom_field.name) @@ -255,19 +254,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): if self.filterset: self.queryset = self.filterset(request.GET, self.queryset).qs - # If this type of object has one or more custom fields, prefetch any relevant custom field values - custom_fields = CustomField.objects.filter( - obj_type=ContentType.objects.get_for_model(model) - ).prefetch_related('choices') - if custom_fields: - self.queryset = self.queryset.prefetch_related('custom_field_values') - # Check for export template rendering if request.GET.get('export'): et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export')) - queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset try: - return et.render_to_response(queryset) + return et.render_to_response(self.queryset) except Exception as e: messages.error( request, @@ -949,38 +940,18 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): elif form.cleaned_data[name] not in (None, ''): setattr(obj, name, form.cleaned_data[name]) - # Cache custom fields on instance prior to save() - if custom_fields: - obj._cf = { - name: form.cleaned_data[name] for name in custom_fields - } + # Update custom fields + for name in custom_fields: + if name in form.nullable_fields and name in nullified_fields: + obj.custom_field_data.pop(name, None) + else: + obj.custom_field_data[name] = form.cleaned_data[name] obj.full_clean() obj.save() updated_objects.append(obj) logger.debug(f"Saved {obj} (PK: {obj.pk})") - # Update custom fields - obj_type = ContentType.objects.get_for_model(model) - for name in custom_fields: - field = form.fields[name].model - if name in form.nullable_fields and name in nullified_fields: - CustomFieldValue.objects.filter( - field=field, obj_type=obj_type, obj_id=obj.pk - ).delete() - elif form.cleaned_data[name] not in [None, '']: - try: - cfv = CustomFieldValue.objects.get( - field=field, obj_type=obj_type, obj_id=obj.pk - ) - except CustomFieldValue.DoesNotExist: - cfv = CustomFieldValue( - field=field, obj_type=obj_type, obj_id=obj.pk - ) - cfv.value = form.cleaned_data[name] - cfv.save() - logger.debug(f"Saved custom fields for {obj} (PK: {obj.pk})") - # Add/remove tags if form.cleaned_data.get('add_tags', None): obj.tags.add(*form.cleaned_data['add_tags']) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index fb61c5b9e..00d444de9 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -150,11 +150,6 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -275,11 +270,6 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): comments = models.TextField( blank=True ) - custom_field_values = GenericRelation( - to='extras.CustomFieldValue', - content_type_field='obj_type', - object_id_field='obj_id' - ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() From c85a45e5208d21c9b48dd705ecfa6a1e520b548c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Aug 2020 14:11:13 -0400 Subject: [PATCH 007/291] Further work on custom fields --- netbox/extras/api/customfields.py | 4 +- netbox/extras/forms.py | 19 +--- netbox/extras/models/customfields.py | 10 ++ netbox/extras/tests/test_customfields.py | 126 ++++++++++------------- 4 files changed, 67 insertions(+), 92 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index a0238129b..df00d0c1d 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -148,10 +148,10 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): fields = CustomField.objects.filter(obj_type=content_type) # Populate CustomFieldValues for each instance from database - try: + if type(self.instance) in (list, tuple): for obj in self.instance: self._populate_custom_fields(obj, fields) - except TypeError: + else: self._populate_custom_fields(self.instance, fields) def _populate_custom_fields(self, instance, custom_fields): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 40c675c4d..96290ef0a 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -57,26 +57,13 @@ class CustomFieldModelForm(forms.ModelForm): # Annotate the field in the list of CustomField form fields self.custom_fields.append(field_name) - def _save_custom_fields(self): - - for field_name in self.custom_fields: - self.instance.custom_field_data[field_name[3:]] = self.cleaned_data[field_name] - def save(self, commit=True): - # Cache custom field values on object prior to save to ensure change logging + # Save custom field data on instance for cf_name in self.custom_fields: - self.instance._cf[cf_name[3:]] = self.cleaned_data.get(cf_name) + self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name) - obj = super().save(commit) - - # Handle custom fields the same way we do M2M fields - if commit: - self._save_custom_fields() - else: - obj.save_custom_fields = self._save_custom_fields - - return obj + return super().save(commit) class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index b0ea76cef..166ef5708 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from datetime import date from django import forms @@ -34,6 +35,15 @@ class CustomFieldModel(models.Model): """ return self.custom_field_data + def get_custom_fields(self): + """ + Return a dictionary of custom fields for a single object in the form {: value}. + """ + fields = CustomField.objects.get_for_model(self) + return OrderedDict([ + (field, self.custom_field_data.get(field.name)) for field in fields + ]) + class CustomFieldManager(models.Manager): use_in_migrations = True diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 74c0e7c3b..71254ac05 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -174,7 +174,7 @@ class CustomFieldAPITest(APITestCase): } cls.sites[1].save() - def test_get_single_object_without_custom_field_values(self): + def test_get_single_object_without_custom_field_data(self): """ Validate that custom fields are present on an object even if it has no values defined. """ @@ -192,13 +192,11 @@ class CustomFieldAPITest(APITestCase): 'choice_field': None, }) - def test_get_single_object_with_custom_field_values(self): + def test_get_single_object_with_custom_field_data(self): """ Validate that custom fields are present and correctly set for an object with values defined. """ - site2_cfvs = { - cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all() - } + site2_cfvs = self.sites[1].custom_field_data url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) self.add_permissions('dcim.view_site') @@ -236,15 +234,12 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data['id']) - cfvs = { - cfv.field.name: cfv.value for cfv in site.custom_field_values.all() - } - self.assertEqual(cfvs['text_field'], self.cf_text.default) - self.assertEqual(cfvs['number_field'], self.cf_integer.default) - self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(cfvs['date_field']), self.cf_date.default) - self.assertEqual(cfvs['url_field'], self.cf_url.default) - self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk) + self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) + self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) + self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) + self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) + self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) + self.assertEqual(site.custom_field_data['choice_field'].pk, self.cf_select_choice1.pk) def test_create_single_object_with_values(self): """ @@ -280,15 +275,12 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data['id']) - cfvs = { - cfv.field.name: cfv.value for cfv in site.custom_field_values.all() - } - self.assertEqual(cfvs['text_field'], data_cf['text_field']) - self.assertEqual(cfvs['number_field'], data_cf['number_field']) - self.assertEqual(cfvs['boolean_field'], data_cf['boolean_field']) - self.assertEqual(str(cfvs['date_field']), data_cf['date_field']) - self.assertEqual(cfvs['url_field'], data_cf['url_field']) - self.assertEqual(cfvs['choice_field'].pk, data_cf['choice_field']) + self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) + self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) + self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) + self.assertEqual(site.custom_field_data['choice_field'].pk, data_cf['choice_field']) def test_create_multiple_objects_with_defaults(self): """ @@ -329,15 +321,12 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data[i]['id']) - cfvs = { - cfv.field.name: cfv.value for cfv in site.custom_field_values.all() - } - self.assertEqual(cfvs['text_field'], self.cf_text.default) - self.assertEqual(cfvs['number_field'], self.cf_integer.default) - self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(cfvs['date_field']), self.cf_date.default) - self.assertEqual(cfvs['url_field'], self.cf_url.default) - self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk) + self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) + self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) + self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) + self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) + self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) + self.assertEqual(site.custom_field_data['choice_field'].pk, self.cf_select_choice1.pk) def test_create_multiple_objects_with_values(self): """ @@ -388,24 +377,20 @@ class CustomFieldAPITest(APITestCase): # Validate database data site = Site.objects.get(pk=response.data[i]['id']) - cfvs = { - cfv.field.name: cfv.value for cfv in site.custom_field_values.all() - } - self.assertEqual(cfvs['text_field'], custom_field_data['text_field']) - self.assertEqual(cfvs['number_field'], custom_field_data['number_field']) - self.assertEqual(cfvs['boolean_field'], custom_field_data['boolean_field']) - self.assertEqual(str(cfvs['date_field']), custom_field_data['date_field']) - self.assertEqual(cfvs['url_field'], custom_field_data['url_field']) - self.assertEqual(cfvs['choice_field'].pk, custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field']) + self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) + self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) + self.assertEqual(site.custom_field_data['choice_field'].pk, custom_field_data['choice_field']) def test_update_single_object_with_values(self): """ Update an object with existing custom field values. Ensure that only the updated custom field values are modified. """ - site2_original_cfvs = { - cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all() - } + site = self.sites[1] + original_cfvs = {**site.custom_field_data} data = { 'custom_fields': { 'text_field': 'ABCD', @@ -430,15 +415,13 @@ class CustomFieldAPITest(APITestCase): # self.assertEqual(response_cf['choice_field']['label'], site2_original_cfvs['choice_field'].value) # Validate database data - site2_updated_cfvs = { - cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all() - } - self.assertEqual(site2_updated_cfvs['text_field'], data_cf['text_field']) - self.assertEqual(site2_updated_cfvs['number_field'], data_cf['number_field']) - self.assertEqual(site2_updated_cfvs['boolean_field'], site2_original_cfvs['boolean_field']) - self.assertEqual(site2_updated_cfvs['date_field'], site2_original_cfvs['date_field']) - self.assertEqual(site2_updated_cfvs['url_field'], site2_original_cfvs['url_field']) - self.assertEqual(site2_updated_cfvs['choice_field'], site2_original_cfvs['choice_field']) + site.refresh_from_db() + self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) + self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) + self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) + self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) class CustomFieldChoiceAPITest(APITestCase): @@ -514,31 +497,26 @@ class CustomFieldImportTest(TestCase): self.assertEqual(response.status_code, 200) # Validate data for site 1 - custom_field_values = { - cf.name: value for cf, value in Site.objects.get(name='Site 1').custom_field_data - } - self.assertEqual(len(custom_field_values), 6) - self.assertEqual(custom_field_values['text'], 'ABC') - self.assertEqual(custom_field_values['integer'], 123) - self.assertEqual(custom_field_values['boolean'], True) - self.assertEqual(custom_field_values['date'], date(2020, 1, 1)) - self.assertEqual(custom_field_values['url'], 'http://example.com/1') - self.assertEqual(custom_field_values['select'].value, 'Choice A') + site1 = Site.objects.get(name='Site 1') + self.assertEqual(len(site1.custom_field_data), 6) + self.assertEqual(site1.custom_field_data['text'], 'ABC') + self.assertEqual(site1.custom_field_data['integer'], 123) + self.assertEqual(site1.custom_field_data['boolean'], True) + self.assertEqual(site1.custom_field_data['date'], date(2020, 1, 1)) + self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') + self.assertEqual(site1.custom_field_data['select'].value, 'Choice A') # Validate data for site 2 - custom_field_values = { - cf.name: value for cf, value in Site.objects.get(name='Site 2').custom_field_data - } - self.assertEqual(len(custom_field_values), 6) - self.assertEqual(custom_field_values['text'], 'DEF') - self.assertEqual(custom_field_values['integer'], 456) - self.assertEqual(custom_field_values['boolean'], False) - self.assertEqual(custom_field_values['date'], date(2020, 1, 2)) - self.assertEqual(custom_field_values['url'], 'http://example.com/2') - self.assertEqual(custom_field_values['select'].value, 'Choice B') + site2 = Site.objects.get(name='Site 2') + self.assertEqual(len(site2.custom_field_data), 6) + self.assertEqual(site2.custom_field_data['text'], 'DEF') + self.assertEqual(site2.custom_field_data['integer'], 456) + self.assertEqual(site2.custom_field_data['boolean'], False) + self.assertEqual(site2.custom_field_data['date'], date(2020, 1, 2)) + self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') + self.assertEqual(site2.custom_field_data['select'].value, 'Choice B') # No CustomFieldValues should be created for site 3 - obj_type = ContentType.objects.get_for_model(Site) site3 = Site.objects.get(name='Site 3') self.assertEqual(site3.custom_field_data, {}) From d9e5adc03270eb225043267b5dd8fcc03168d2b7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 10:42:22 -0400 Subject: [PATCH 008/291] Update serializer to access custom_field_data directly --- netbox/extras/api/customfields.py | 36 +----------------------- netbox/extras/tests/test_customfields.py | 16 +++++------ 2 files changed, 8 insertions(+), 44 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index df00d0c1d..536b65439 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -2,7 +2,6 @@ from datetime import datetime from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.fields import CreateOnlyDefault @@ -134,6 +133,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): Extends ModelSerializer to render any CustomFields and their values associated with an object. """ custom_fields = CustomFieldsSerializer( + source='custom_field_data', required=False, default=CreateOnlyDefault(CustomFieldDefaultValues()) ) @@ -163,40 +163,6 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): else: instance.custom_fields[field.name] = value - def _save_custom_fields(self, instance, custom_fields): - for field_name, value in custom_fields.items(): - instance.custom_field_data[field_name] = value - - def create(self, validated_data): - - with transaction.atomic(): - - instance = super().create(validated_data) - - # Save custom fields - custom_fields = validated_data.get('custom_fields') - if custom_fields is not None: - self._save_custom_fields(instance, custom_fields) - instance.custom_fields = custom_fields - - return instance - - def update(self, instance, validated_data): - - with transaction.atomic(): - - custom_fields = validated_data.get('custom_fields') - instance._cf = custom_fields - - instance = super().update(instance, validated_data) - - # Save custom fields - if custom_fields is not None: - self._save_custom_fields(instance, custom_fields) - instance.custom_fields = custom_fields - - return instance - class CustomFieldChoiceSerializer(serializers.ModelSerializer): """ diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 71254ac05..16785854f 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,5 +1,3 @@ -from datetime import date - from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status @@ -30,7 +28,7 @@ class CustomFieldTest(TestCase): {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 42, 'empty_value': None}, {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': True, 'empty_value': None}, {'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN, 'field_value': False, 'empty_value': None}, - {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None}, + {'field_type': CustomFieldTypeChoices.TYPE_DATE, 'field_value': '2016-06-23', 'empty_value': None}, {'field_type': CustomFieldTypeChoices.TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''}, ) @@ -239,7 +237,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['choice_field'].pk, self.cf_select_choice1.pk) + self.assertEqual(site.custom_field_data['choice_field'], self.cf_select_choice1.pk) def test_create_single_object_with_values(self): """ @@ -280,7 +278,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) - self.assertEqual(site.custom_field_data['choice_field'].pk, data_cf['choice_field']) + self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) def test_create_multiple_objects_with_defaults(self): """ @@ -326,7 +324,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['choice_field'].pk, self.cf_select_choice1.pk) + self.assertEqual(site.custom_field_data['choice_field'], self.cf_select_choice1.pk) def test_create_multiple_objects_with_values(self): """ @@ -382,7 +380,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) - self.assertEqual(site.custom_field_data['choice_field'].pk, custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) def test_update_single_object_with_values(self): """ @@ -502,7 +500,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['integer'], 123) self.assertEqual(site1.custom_field_data['boolean'], True) - self.assertEqual(site1.custom_field_data['date'], date(2020, 1, 1)) + self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') self.assertEqual(site1.custom_field_data['select'].value, 'Choice A') @@ -512,7 +510,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['integer'], 456) self.assertEqual(site2.custom_field_data['boolean'], False) - self.assertEqual(site2.custom_field_data['date'], date(2020, 1, 2)) + self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['select'].value, 'Choice B') From f7b8d6ede5d5c082b4db1dc259f10157ea1a17b4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 13:24:46 -0400 Subject: [PATCH 009/291] Add choices ArrayField to CustomField; drop CustomFieldChoice --- netbox/extras/admin.py | 8 +- netbox/extras/api/customfields.py | 55 ++---------- netbox/extras/api/urls.py | 3 - netbox/extras/api/views.py | 44 +--------- .../0050_customfield_add_choices.py | 35 ++++++++ ...values.py => 0051_migrate_customfields.py} | 33 +++++-- ...ete_customfieldchoice_customfieldvalue.py} | 7 +- netbox/extras/models/__init__.py | 3 +- netbox/extras/models/customfields.py | 75 ++++++---------- netbox/extras/tests/test_customfields.py | 86 +++++-------------- 10 files changed, 127 insertions(+), 222 deletions(-) create mode 100644 netbox/extras/migrations/0050_customfield_add_choices.py rename netbox/extras/migrations/{0050_migrate_customfieldvalues.py => 0051_migrate_customfields.py} (57%) rename netbox/extras/migrations/{0051_delete_customfieldvalue.py => 0052_delete_customfieldchoice_customfieldvalue.py} (61%) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 5c95025cd..4bd738160 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,7 +2,7 @@ from django import forms from django.contrib import admin from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, CustomLink, ExportTemplate, JobResult, Webhook +from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook def order_content_types(field): @@ -81,14 +81,8 @@ class CustomFieldForm(forms.ModelForm): order_content_types(self.fields['obj_type']) -class CustomFieldChoiceAdmin(admin.TabularInline): - model = CustomFieldChoice - extra = 5 - - @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): - inlines = [CustomFieldChoiceAdmin] list_display = [ 'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description', ] diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 536b65439..a053642db 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -7,7 +7,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.fields import CreateOnlyDefault from extras.choices import * -from extras.models import CustomField, CustomFieldChoice +from extras.models import CustomField from utilities.api import ValidatedModelSerializer @@ -37,12 +37,6 @@ class CustomFieldDefaultValues: elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN: # TODO: Fix default value assignment for boolean custom fields field_value = False if field.default.lower() == 'false' else bool(field.default) - elif field.type == CustomFieldTypeChoices.TYPE_SELECT: - try: - field_value = field.choices.get(value=field.default).pk - except ObjectDoesNotExist: - # Invalid default value - field_value = None else: field_value = field.default value[field.name] = field_value @@ -69,9 +63,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer): try: cf = custom_fields[field_name] except KeyError: - raise ValidationError( - "Invalid custom field for {} objects: {}".format(content_type, field_name) - ) + raise ValidationError(f"Invalid custom field for {content_type} objects: {field_name}") # Data validation if value not in [None, '']: @@ -81,15 +73,11 @@ class CustomFieldsSerializer(serializers.BaseSerializer): try: int(value) except ValueError: - raise ValidationError( - "Invalid value for integer field {}: {}".format(field_name, value) - ) + raise ValidationError(f"Invalid value for integer field {field_name}: {value}") # Validate boolean if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: - raise ValidationError( - "Invalid value for boolean field {}: {}".format(field_name, value) - ) + raise ValidationError(f"Invalid value for boolean field {field_name}: {value}") # Validate date if cf.type == CustomFieldTypeChoices.TYPE_DATE: @@ -97,25 +85,16 @@ class CustomFieldsSerializer(serializers.BaseSerializer): datetime.strptime(value, '%Y-%m-%d') except ValueError: raise ValidationError( - "Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value) + f"Invalid date for field {field_name}: {value}. (Required format is YYYY-MM-DD.)" ) # Validate selected choice if cf.type == CustomFieldTypeChoices.TYPE_SELECT: - try: - value = int(value) - except ValueError: - raise ValidationError( - "{}: Choice selections must be passed as integers.".format(field_name) - ) - valid_choices = [c.pk for c in cf.choices.all()] - if value not in valid_choices: - raise ValidationError( - "Invalid choice for field {}: {}".format(field_name, value) - ) + if value not in cf.choices: + raise ValidationError(f"Invalid choice for field {field_name}: {value}") elif cf.required: - raise ValidationError("Required field {} cannot be empty.".format(field_name)) + raise ValidationError(f"Required field {field_name} cannot be empty.") # Check for missing required fields missing_fields = [] @@ -157,20 +136,4 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): def _populate_custom_fields(self, instance, custom_fields): instance.custom_fields = {} for field in custom_fields: - value = instance.cf.get(field.name) - if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None: - instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data - else: - instance.custom_fields[field.name] = value - - -class CustomFieldChoiceSerializer(serializers.ModelSerializer): - """ - Imitate utilities.api.ChoiceFieldSerializer - """ - value = serializers.IntegerField(source='pk') - label = serializers.CharField(source='value') - - class Meta: - model = CustomFieldChoice - fields = ['value', 'label'] + instance.custom_fields[field.name] = instance.cf.get(field.name) diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 70e5bc9da..20d0f8d17 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,9 +5,6 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.ExtrasRootView -# Custom field choices -router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') - # Export templates router.register('export-templates', views.ExportTemplateViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 5be8276b6..278caeda7 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -14,9 +14,7 @@ from rq import Worker from extras import filters from extras.choices import JobResultStatusChoices -from extras.models import ( - ConfigContext, CustomFieldChoice, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, -) +from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet @@ -34,36 +32,6 @@ class ExtrasRootView(APIRootView): return 'Extras' -# -# Custom field choices -# - -class CustomFieldChoicesViewSet(ViewSet): - """ - """ - permission_classes = [IsAuthenticatedOrLoginNotRequired] - - def __init__(self, *args, **kwargs): - super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs) - - self._fields = OrderedDict() - - for cfc in CustomFieldChoice.objects.all(): - self._fields.setdefault(cfc.field.name, {}) - self._fields[cfc.field.name][cfc.value] = cfc.pk - - def list(self, request): - return Response(self._fields) - - def retrieve(self, request, pk): - if pk not in self._fields: - raise Http404 - return Response(self._fields[pk]) - - def get_view_name(self): - return "Custom Field choices" - - # # Custom fields # @@ -77,19 +45,11 @@ class CustomFieldModelViewSet(ModelViewSet): # Gather all custom fields for the model content_type = ContentType.objects.get_for_model(self.queryset.model) - custom_fields = content_type.custom_fields.prefetch_related('choices') - - # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object. - custom_field_choices = {} - for field in custom_fields: - for cfc in field.choices.all(): - custom_field_choices[cfc.id] = cfc.value - custom_field_choices = custom_field_choices + custom_fields = content_type.custom_fields.all() context = super().get_serializer_context() context.update({ 'custom_fields': custom_fields, - 'custom_field_choices': custom_field_choices, }) return context diff --git a/netbox/extras/migrations/0050_customfield_add_choices.py b/netbox/extras/migrations/0050_customfield_add_choices.py new file mode 100644 index 000000000..1ae63a2c3 --- /dev/null +++ b/netbox/extras/migrations/0050_customfield_add_choices.py @@ -0,0 +1,35 @@ +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0049_remove_graph'), + ] + + operations = [ + # Rename reverse relation on CustomFieldChoice + migrations.AlterField( + model_name='customfieldchoice', + name='field', + field=models.ForeignKey( + limit_choices_to={'type': 'select'}, + on_delete=django.db.models.deletion.CASCADE, + related_name='_choices', + to='extras.customfield' + ), + ), + # Add choices field to CustomField + migrations.AddField( + model_name='customfield', + name='choices', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=100), + blank=True, + null=True, + size=None + ), + ), + ] diff --git a/netbox/extras/migrations/0050_migrate_customfieldvalues.py b/netbox/extras/migrations/0051_migrate_customfields.py similarity index 57% rename from netbox/extras/migrations/0050_migrate_customfieldvalues.py rename to netbox/extras/migrations/0051_migrate_customfields.py index 9b2402267..017201259 100644 --- a/netbox/extras/migrations/0050_migrate_customfieldvalues.py +++ b/netbox/extras/migrations/0051_migrate_customfields.py @@ -3,18 +3,38 @@ from django.db import migrations from extras.choices import CustomFieldTypeChoices -def deserialize_value(field_type, value): +def deserialize_value(field, value): """ Convert serialized values to JSON equivalents. """ - if field_type in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_SELECT): + if field.type in (CustomFieldTypeChoices.TYPE_INTEGER): return int(value) - if field_type == CustomFieldTypeChoices.TYPE_BOOLEAN: + if field.type == CustomFieldTypeChoices.TYPE_BOOLEAN: return bool(int(value)) + if field.type == CustomFieldTypeChoices.TYPE_SELECT: + return field._choices.get(pk=int(value)).value return value +def migrate_customfieldchoices(apps, schema_editor): + """ + Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on + the CustomField instance. + """ + CustomField = apps.get_model('extras', 'CustomField') + CustomFieldChoice = apps.get_model('extras', 'CustomFieldChoice') + + for cf in CustomField.objects.filter(type='select'): + cf.choices = [ + cfc.value for cfc in CustomFieldChoice.objects.filter(field=cf).order_by('weight', 'value') + ] + cf.save() + + def migrate_customfieldvalues(apps, schema_editor): + """ + Copy data from CustomFieldValues into the custom_field_data JSON field on each model instance. + """ CustomFieldValue = apps.get_model('extras', 'CustomFieldValue') for cfv in CustomFieldValue.objects.prefetch_related('field').exclude(serialized_value=''): @@ -24,7 +44,7 @@ def migrate_customfieldvalues(apps, schema_editor): # TODO: This can be done more efficiently once .update() is supported for JSON fields cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first() try: - cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field.type, cfv.serialized_value) + cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value) except ValueError as e: print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})') raise e @@ -36,7 +56,7 @@ class Migration(migrations.Migration): dependencies = [ ('circuits', '0020_custom_field_data'), ('dcim', '0115_custom_field_data'), - ('extras', '0049_remove_graph'), + ('extras', '0050_customfield_add_choices'), ('ipam', '0038_custom_field_data'), ('secrets', '0010_custom_field_data'), ('tenancy', '0010_custom_field_data'), @@ -44,6 +64,9 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython( + code=migrate_customfieldchoices + ), migrations.RunPython( code=migrate_customfieldvalues ), diff --git a/netbox/extras/migrations/0051_delete_customfieldvalue.py b/netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py similarity index 61% rename from netbox/extras/migrations/0051_delete_customfieldvalue.py rename to netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py index 3369289a0..8b5e2ba45 100644 --- a/netbox/extras/migrations/0051_delete_customfieldvalue.py +++ b/netbox/extras/migrations/0052_delete_customfieldchoice_customfieldvalue.py @@ -1,15 +1,16 @@ -# Generated by Django 3.1 on 2020-08-21 19:52 - from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('extras', '0050_migrate_customfieldvalues'), + ('extras', '0051_migrate_customfields'), ] operations = [ + migrations.DeleteModel( + name='CustomFieldChoice', + ), migrations.DeleteModel( name='CustomFieldValue', ), diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index a4178b911..c6191bbd2 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,5 +1,5 @@ from .change_logging import ChangeLoggedModel, ObjectChange -from .customfields import CustomField, CustomFieldChoice, CustomFieldModel +from .customfields import CustomField, CustomFieldModel from .models import ( ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, Webhook, @@ -11,7 +11,6 @@ __all__ = ( 'ConfigContext', 'ConfigContextModel', 'CustomField', - 'CustomFieldChoice', 'CustomFieldModel', 'CustomLink', 'ExportTemplate', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 166ef5708..e922a2f77 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -3,6 +3,7 @@ from datetime import date from django import forms from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField from django.core.validators import ValidationError from django.db import models @@ -11,11 +12,10 @@ from extras.choices import * from extras.utils import FeatureQuery -# -# Custom fields -# - class CustomFieldModel(models.Model): + """ + Abstract class for any model which may have custom fields associated with it. + """ custom_field_data = models.JSONField( blank=True, default=dict @@ -104,6 +104,12 @@ class CustomField(models.Model): default=100, help_text='Fields with higher weights appear lower in a form.' ) + choices = ArrayField( + base_field=models.CharField(max_length=100), + blank=True, + null=True, + help_text='Comma-separated list of available choices (for selection fields)' + ) objects = CustomFieldManager() @@ -113,6 +119,19 @@ class CustomField(models.Model): def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() + def clean(self): + # Choices can be set only on selection fields + if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: + raise ValidationError({ + 'choices': "Choices may be set only for selection-type custom fields." + }) + + # A selection field's default (if any) must be present in its available choices + if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices: + raise ValidationError({ + 'default': f"The specified default value ({self.default}) is not listed as an available choice." + }) + def serialize_value(self, value): """ Serialize the given value to a string suitable for storage as a CustomFieldValue @@ -187,16 +206,14 @@ class CustomField(models.Model): # Select elif self.type == CustomFieldTypeChoices.TYPE_SELECT: - choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] + choices = [(c, c) for c in self.choices] if not required: choices = add_blank_choice(choices) - # Set the initial value to the PK of the default choice, if any - if set_initial: - default_choice = self.choices.filter(value=self.default).first() - if default_choice: - initial = default_choice.pk + # Set the initial value to the first available choice (if any) + if set_initial and self.choices: + initial = self.choices[0] field_class = CSVChoiceField if for_csv_import else forms.ChoiceField field = field_class( @@ -217,41 +234,3 @@ class CustomField(models.Model): field.help_text = self.description return field - - -class CustomFieldChoice(models.Model): - field = models.ForeignKey( - to='extras.CustomField', - on_delete=models.CASCADE, - related_name='choices', - limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT} - ) - value = models.CharField( - max_length=100 - ) - weight = models.PositiveSmallIntegerField( - default=100, - help_text='Higher weights appear lower in the list' - ) - - class Meta: - ordering = ['field', 'weight', 'value'] - unique_together = ['field', 'value'] - - def __str__(self): - return self.value - - def clean(self): - if self.field.type != CustomFieldTypeChoices.TYPE_SELECT: - raise ValidationError("Custom field choices can only be assigned to selection fields.") - - def delete(self, *args, **kwargs): - # TODO: Prevent deletion of CustomFieldChoices which are in use? - field_name = f'custom_field_data__{self.field.name}' - for ct in self.field.obj_type.all(): - model = ct.model_class() - for instance in model.objects.filter(**{field_name: self.pk}): - instance.custom_field_data.pop(self.field.name) - instance.save() - - super().delete(*args, **kwargs) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 16785854f..675248a3b 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -5,7 +5,7 @@ from rest_framework import status from dcim.forms import SiteCSVForm from dcim.models import Site from extras.choices import * -from extras.models import CustomField, CustomFieldChoice +from extras.models import CustomField from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -65,21 +65,19 @@ class CustomFieldTest(TestCase): obj_type = ContentType.objects.get_for_model(Site) # Create a custom field - cf = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='my_field', required=False) + cf = CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT, + name='my_field', + required=False, + choices=['Option A', 'Option B', 'Option C'] + ) cf.save() cf.obj_type.set([obj_type]) cf.save() - # Create some choices for the field - CustomFieldChoice.objects.bulk_create([ - CustomFieldChoice(field=cf, value='Option A'), - CustomFieldChoice(field=cf, value='Option B'), - CustomFieldChoice(field=cf, value='Option C'), - ]) - # Assign a value to the first Site site = Site.objects.first() - site.custom_field_data[cf.name] = cf.choices.first().pk + site.custom_field_data[cf.name] = 'Option A' site.save() # Retrieve the stored value @@ -141,18 +139,10 @@ class CustomFieldAPITest(APITestCase): cls.cf_url.obj_type.set([content_type]) # Select custom field - cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field') + cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) + cls.cf_select.default = 'Foo' cls.cf_select.save() cls.cf_select.obj_type.set([content_type]) - cls.cf_select_choice1 = CustomFieldChoice(field=cls.cf_select, value='Foo') - cls.cf_select_choice1.save() - cls.cf_select_choice2 = CustomFieldChoice(field=cls.cf_select, value='Bar') - cls.cf_select_choice2.save() - cls.cf_select_choice3 = CustomFieldChoice(field=cls.cf_select, value='Baz') - cls.cf_select_choice3.save() - - cls.cf_select.default = cls.cf_select_choice1.value - cls.cf_select.save() # Create some sites cls.sites = ( @@ -168,7 +158,7 @@ class CustomFieldAPITest(APITestCase): cls.cf_boolean.name: True, cls.cf_date.name: '2020-01-02', cls.cf_url.name: 'http://example.com/2', - cls.cf_select.name: cls.cf_select_choice2.pk, + cls.cf_select.name: 'Bar', } cls.sites[1].save() @@ -205,7 +195,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field']) self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) - self.assertEqual(response.data['custom_fields']['choice_field']['label'], self.cf_select_choice2.value) + self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) def test_create_single_object_with_defaults(self): """ @@ -228,7 +218,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk) + self.assertEqual(response_cf['choice_field'], self.cf_select.default) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -237,7 +227,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select_choice1.pk) + self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) def test_create_single_object_with_values(self): """ @@ -252,7 +242,7 @@ class CustomFieldAPITest(APITestCase): 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', - 'choice_field': self.cf_select_choice2.pk, + 'choice_field': 'Bar', }, } url = reverse('dcim-api:site-list') @@ -315,7 +305,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk) + self.assertEqual(response_cf['choice_field'], self.cf_select.default) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -324,7 +314,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select_choice1.pk) + self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) def test_create_multiple_objects_with_values(self): """ @@ -336,7 +326,7 @@ class CustomFieldAPITest(APITestCase): 'boolean_field': True, 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', - 'choice_field': self.cf_select_choice2.pk, + 'choice_field': 'Bar', } data = ( { @@ -410,7 +400,7 @@ class CustomFieldAPITest(APITestCase): # self.assertEqual(response_cf['boolean_field'], site2_original_cfvs['boolean_field']) # self.assertEqual(response_cf['date_field'], site2_original_cfvs['date_field']) # self.assertEqual(response_cf['url_field'], site2_original_cfvs['url_field']) - # self.assertEqual(response_cf['choice_field']['label'], site2_original_cfvs['choice_field'].value) + # self.assertEqual(response_cf['choice_field'], site2_original_cfvs['choice_field'].value) # Validate database data site.refresh_from_db() @@ -422,36 +412,6 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) -class CustomFieldChoiceAPITest(APITestCase): - def setUp(self): - super().setUp() - - vm_content_type = ContentType.objects.get_for_model(VirtualMachine) - - self.cf_1 = CustomField.objects.create(name="cf_1", type=CustomFieldTypeChoices.TYPE_SELECT) - self.cf_2 = CustomField.objects.create(name="cf_2", type=CustomFieldTypeChoices.TYPE_SELECT) - - self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100) - self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50) - self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10) - - def test_list_cfc(self): - url = reverse('extras-api:custom-field-choice-list') - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data), 2) - self.assertEqual(len(response.data[self.cf_1.name]), 2) - self.assertEqual(len(response.data[self.cf_2.name]), 1) - - self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name]) - self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name]) - self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name]) - - self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value]) - self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value]) - self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value]) - - class CustomFieldImportTest(TestCase): user_permissions = ( 'dcim.view_site', @@ -467,18 +427,12 @@ class CustomFieldImportTest(TestCase): CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), - CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Choice A', 'Choice B', 'Choice C']), ) for cf in custom_fields: cf.save() cf.obj_type.set([ContentType.objects.get_for_model(Site)]) - CustomFieldChoice.objects.bulk_create(( - CustomFieldChoice(field=custom_fields[5], value='Choice A'), - CustomFieldChoice(field=custom_fields[5], value='Choice B'), - CustomFieldChoice(field=custom_fields[5], value='Choice C'), - )) - def test_import(self): """ Import a Site in CSV format, including a value for each CustomField. From fb8904af5476a2624950a708e9bd660a3c5ecc68 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 13:42:47 -0400 Subject: [PATCH 010/291] Remove unused attributes, methods --- netbox/extras/forms.py | 13 +-------- netbox/extras/models/customfields.py | 42 +--------------------------- 2 files changed, 2 insertions(+), 53 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 96290ef0a..2cdd5d2ed 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -25,34 +25,23 @@ class CustomFieldModelForm(forms.ModelForm): self.obj_type = ContentType.objects.get_for_model(self._meta.model) self.custom_fields = [] - self.custom_field_values = {} super().__init__(*args, **kwargs) - if self.instance._cf is None: - self.instance._cf = {} - self._append_customfield_fields() def _append_customfield_fields(self): """ Append form fields for all CustomFields assigned to this model. """ - # Retrieve initial CustomField values for the instance - if self.instance.pk: - self.custom_field_values = self.instance.custom_field_data - # Append form fields; assign initial values if modifying and existing object for cf in CustomField.objects.filter(obj_type=self.obj_type): field_name = 'cf_{}'.format(cf.name) if self.instance.pk: self.fields[field_name] = cf.to_form_field(set_initial=False) - value = self.custom_field_values.get(cf.name) - self.fields[field_name].initial = value - self.instance._cf[cf.name] = value + self.fields[field_name].initial = self.instance.custom_field_data.get(cf.name) else: self.fields[field_name] = cf.to_form_field() - self.instance._cf[cf.name] = self.fields[field_name].initial # Annotate the field in the list of CustomField form fields self.custom_fields.append(field_name) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index e922a2f77..325853d6d 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -24,10 +24,6 @@ class CustomFieldModel(models.Model): class Meta: abstract = True - def __init__(self, *args, custom_fields=None, **kwargs): - self._cf = custom_fields - super().__init__(*args, **kwargs) - @property def cf(self): """ @@ -132,42 +128,6 @@ class CustomField(models.Model): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) - def serialize_value(self, value): - """ - Serialize the given value to a string suitable for storage as a CustomFieldValue - """ - if value is None: - return '' - if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - return str(int(bool(value))) - if self.type == CustomFieldTypeChoices.TYPE_DATE: - # Could be date/datetime object or string - try: - return value.strftime('%Y-%m-%d') - except AttributeError: - return value - if self.type == CustomFieldTypeChoices.TYPE_SELECT: - # Could be ModelChoiceField or TypedChoiceField - return str(value.id) if hasattr(value, 'id') else str(value) - return value - - def deserialize_value(self, serialized_value): - """ - Convert a string into the object it represents depending on the type of field - """ - if serialized_value == '': - return None - if self.type == CustomFieldTypeChoices.TYPE_INTEGER: - return int(serialized_value) - if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - return bool(int(serialized_value)) - if self.type == CustomFieldTypeChoices.TYPE_DATE: - # Read date as YYYY-MM-DD - return date(*[int(n) for n in serialized_value.split('-')]) - if self.type == CustomFieldTypeChoices.TYPE_SELECT: - return self.choices.get(pk=int(serialized_value)) - return serialized_value - def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. @@ -229,7 +189,7 @@ class CustomField(models.Model): field = forms.CharField(max_length=255, required=required, initial=initial) field.model = self - field.label = self.label if self.label else self.name.replace('_', ' ').capitalize() + field.label = str(self) if self.description: field.help_text = self.description From 5b3de8defe125c87cf0d18aeeddf9c91c4446713 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 13:57:18 -0400 Subject: [PATCH 011/291] Use DjangoJSONEncoder for encoding custom field data --- .../circuits/migrations/0020_custom_field_data.py | 7 +++---- netbox/dcim/migrations/0115_custom_field_data.py | 13 ++++++------- netbox/extras/models/customfields.py | 3 ++- netbox/ipam/migrations/0038_custom_field_data.py | 15 +++++++-------- .../secrets/migrations/0010_custom_field_data.py | 5 ++--- .../tenancy/migrations/0010_custom_field_data.py | 5 ++--- .../migrations/0018_custom_field_data.py | 7 +++---- 7 files changed, 25 insertions(+), 30 deletions(-) diff --git a/netbox/circuits/migrations/0020_custom_field_data.py b/netbox/circuits/migrations/0020_custom_field_data.py index c72b937db..97da9962c 100644 --- a/netbox/circuits/migrations/0020_custom_field_data.py +++ b/netbox/circuits/migrations/0020_custom_field_data.py @@ -1,5 +1,4 @@ -# Generated by Django 3.1 on 2020-08-21 18:34 - +import django.core.serializers.json from django.db import migrations, models @@ -13,11 +12,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='circuit', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='provider', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), ] diff --git a/netbox/dcim/migrations/0115_custom_field_data.py b/netbox/dcim/migrations/0115_custom_field_data.py index 58c320902..e6353c916 100644 --- a/netbox/dcim/migrations/0115_custom_field_data.py +++ b/netbox/dcim/migrations/0115_custom_field_data.py @@ -1,5 +1,4 @@ -# Generated by Django 3.1 on 2020-08-21 18:34 - +import django.core.serializers.json from django.db import migrations, models @@ -13,26 +12,26 @@ class Migration(migrations.Migration): migrations.AddField( model_name='device', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='devicetype', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='powerfeed', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='rack', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='site', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 325853d6d..422438539 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,9 +1,9 @@ from collections import OrderedDict -from datetime import date from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models @@ -17,6 +17,7 @@ class CustomFieldModel(models.Model): Abstract class for any model which may have custom fields associated with it. """ custom_field_data = models.JSONField( + encoder=DjangoJSONEncoder, blank=True, default=dict ) diff --git a/netbox/ipam/migrations/0038_custom_field_data.py b/netbox/ipam/migrations/0038_custom_field_data.py index c9d66975c..86d51e9b8 100644 --- a/netbox/ipam/migrations/0038_custom_field_data.py +++ b/netbox/ipam/migrations/0038_custom_field_data.py @@ -1,5 +1,4 @@ -# Generated by Django 3.1 on 2020-08-21 18:34 - +import django.core.serializers.json from django.db import migrations, models @@ -13,31 +12,31 @@ class Migration(migrations.Migration): migrations.AddField( model_name='aggregate', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='ipaddress', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='prefix', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='service', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='vlan', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='vrf', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), ] diff --git a/netbox/secrets/migrations/0010_custom_field_data.py b/netbox/secrets/migrations/0010_custom_field_data.py index 33b5f06e1..6d48e7cab 100644 --- a/netbox/secrets/migrations/0010_custom_field_data.py +++ b/netbox/secrets/migrations/0010_custom_field_data.py @@ -1,5 +1,4 @@ -# Generated by Django 3.1 on 2020-08-21 18:34 - +import django.core.serializers.json from django.db import migrations, models @@ -13,6 +12,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='secret', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), ] diff --git a/netbox/tenancy/migrations/0010_custom_field_data.py b/netbox/tenancy/migrations/0010_custom_field_data.py index 4d05a8272..ec05be0ff 100644 --- a/netbox/tenancy/migrations/0010_custom_field_data.py +++ b/netbox/tenancy/migrations/0010_custom_field_data.py @@ -1,5 +1,4 @@ -# Generated by Django 3.1 on 2020-08-21 18:34 - +import django.core.serializers.json from django.db import migrations, models @@ -13,6 +12,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='tenant', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), ] diff --git a/netbox/virtualization/migrations/0018_custom_field_data.py b/netbox/virtualization/migrations/0018_custom_field_data.py index 5c129e2ab..9a120406a 100644 --- a/netbox/virtualization/migrations/0018_custom_field_data.py +++ b/netbox/virtualization/migrations/0018_custom_field_data.py @@ -1,5 +1,4 @@ -# Generated by Django 3.1 on 2020-08-21 18:34 - +import django.core.serializers.json from django.db import migrations, models @@ -13,11 +12,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='cluster', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), migrations.AddField( model_name='virtualmachine', name='custom_field_data', - field=models.JSONField(blank=True, default=dict), + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), ] From 0b7d019c024823f919bb77aa22f2a1bf1e9487b5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 15:14:49 -0400 Subject: [PATCH 012/291] Remove unused CustomChoiceFieldInspector --- netbox/netbox/settings.py | 1 - netbox/utilities/custom_inspectors.py | 42 --------------------------- 2 files changed, 43 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d49e41b76..92f39cf44 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -486,7 +486,6 @@ SWAGGER_SETTINGS = { 'DEFAULT_FIELD_INSPECTORS': [ 'utilities.custom_inspectors.JSONFieldInspector', 'utilities.custom_inspectors.NullableBooleanFieldInspector', - 'utilities.custom_inspectors.CustomChoiceFieldInspector', 'utilities.custom_inspectors.SerializedPKRelatedFieldInspector', 'drf_yasg.inspectors.CamelCaseJSONFilter', 'drf_yasg.inspectors.ReferencingSerializerInspector', diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 38297838d..e013bf82a 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -5,7 +5,6 @@ from drf_yasg.utils import get_serializer_ref_name from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField -from extras.api.customfields import CustomFieldsSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer @@ -49,47 +48,6 @@ class SerializedPKRelatedFieldInspector(FieldInspector): return NotHandled -class CustomChoiceFieldInspector(FieldInspector): - def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): - # this returns a callable which extracts title, description and other stuff - # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types - SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - - if isinstance(field, ChoiceField): - choices = field._choices - choice_value = list(choices.keys()) - choice_label = list(choices.values()) - value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value) - - if set([None] + choice_value) == {None, True, False}: - # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be - # differentiated since they each have subtly different values in their choice keys. - # - subdevice_role and connection_status are booleans, although subdevice_role includes None - # - face is an integer set {0, 1} which is easily confused with {False, True} - schema_type = openapi.TYPE_STRING - if all(type(x) == bool for x in [c for c in choice_value if c is not None]): - schema_type = openapi.TYPE_BOOLEAN - value_schema = openapi.Schema(type=schema_type, enum=choice_value) - value_schema['x-nullable'] = True - - if all(type(x) == int for x in [c for c in choice_value if c is not None]): - # Change value_schema for IPAddressFamilyChoices, RackWidthChoices - value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value) - - schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={ - "label": openapi.Schema(type=openapi.TYPE_STRING, enum=choice_label), - "value": value_schema - }) - - return schema - - elif isinstance(field, CustomFieldsSerializer): - schema = SwaggerType(type=openapi.TYPE_OBJECT) - return schema - - return NotHandled - - class NullableBooleanFieldInspector(FieldInspector): def process_result(self, result, method_name, obj, **kwargs): From d0f1c733e78c0812c2db9f7ea51d66159d5676f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 15:22:32 -0400 Subject: [PATCH 013/291] Replace CustomFieldsSerializer with CustomFieldsDataField --- netbox/extras/api/customfields.py | 16 ++++++++++------ netbox/extras/tests/test_customfields.py | 22 ++++++++++------------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index a053642db..393e4545f 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,10 +1,9 @@ from datetime import datetime from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from rest_framework.exceptions import ValidationError -from rest_framework.fields import CreateOnlyDefault +from rest_framework.fields import CreateOnlyDefault, Field from extras.choices import * from extras.models import CustomField @@ -46,12 +45,18 @@ class CustomFieldDefaultValues: return value -class CustomFieldsSerializer(serializers.BaseSerializer): +class CustomFieldsDataField(Field): def to_representation(self, obj): - return obj + content_type = ContentType.objects.get_for_model(self.parent.Meta.model) + custom_fields = CustomField.objects.filter(obj_type=content_type) + + return {cf.name: obj.get(cf.name) for cf in custom_fields} def to_internal_value(self, data): + # If updating an existing instance, start with existing custom_field_data + if self.parent.instance: + data = {**self.parent.instance.custom_field_data, **data} content_type = ContentType.objects.get_for_model(self.parent.Meta.model) custom_fields = { @@ -111,9 +116,8 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): """ Extends ModelSerializer to render any CustomFields and their values associated with an object. """ - custom_fields = CustomFieldsSerializer( + custom_fields = CustomFieldsDataField( source='custom_field_data', - required=False, default=CreateOnlyDefault(CustomFieldDefaultValues()) ) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 675248a3b..31d3c2be9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -393,19 +393,17 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] - data_cf = data['custom_fields'] - self.assertEqual(response_cf['text_field'], data_cf['text_field']) - self.assertEqual(response_cf['number_field'], data_cf['number_field']) - # TODO: Non-updated fields are missing from the response data - # self.assertEqual(response_cf['boolean_field'], site2_original_cfvs['boolean_field']) - # self.assertEqual(response_cf['date_field'], site2_original_cfvs['date_field']) - # self.assertEqual(response_cf['url_field'], site2_original_cfvs['url_field']) - # self.assertEqual(response_cf['choice_field'], site2_original_cfvs['choice_field'].value) + self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field']) + self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field']) + self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) + self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) + self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) # Validate database data site.refresh_from_db() - self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) - self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) + self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) + self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field']) self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) @@ -456,7 +454,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') - self.assertEqual(site1.custom_field_data['select'].value, 'Choice A') + self.assertEqual(site1.custom_field_data['select'], 'Choice A') # Validate data for site 2 site2 = Site.objects.get(name='Site 2') @@ -466,7 +464,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') - self.assertEqual(site2.custom_field_data['select'].value, 'Choice B') + self.assertEqual(site2.custom_field_data['select'], 'Choice B') # No CustomFieldValues should be created for site 3 site3 = Site.objects.get(name='Site 3') From a9086b06795e0fdedf4e1614542f101c5b1c670d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 15:31:01 -0400 Subject: [PATCH 014/291] Fix import test --- netbox/extras/tests/test_customfields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 31d3c2be9..38d904d41 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -466,9 +466,9 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['select'], 'Choice B') - # No CustomFieldValues should be created for site 3 + # No custom field data should be set for site 3 site3 = Site.objects.get(name='Site 3') - self.assertEqual(site3.custom_field_data, {}) + self.assertFalse(any(site3.custom_field_data.values())) def test_import_missing_required(self): """ From 378c0ac2594537af106b0aca879d19df8d2f31ae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 16:21:54 -0400 Subject: [PATCH 015/291] Fix filtering by custom field value --- netbox/extras/filters.py | 47 +++++++++++++--------------------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 73811c063..c7116fa0d 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -21,15 +21,20 @@ __all__ = ( 'TagFilterSet', ) +EXACT_FILTER_TYPES = ( + CustomFieldTypeChoices.TYPE_BOOLEAN, + CustomFieldTypeChoices.TYPE_DATE, + CustomFieldTypeChoices.TYPE_INTEGER, + CustomFieldTypeChoices.TYPE_SELECT, +) + class CustomFieldFilter(django_filters.Filter): """ Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. """ - def __init__(self, custom_field, *args, **kwargs): - self.cf_type = custom_field.type - self.filter_logic = custom_field.filter_logic + self.custom_field = custom_field super().__init__(*args, **kwargs) def filter(self, queryset, value): @@ -38,44 +43,22 @@ class CustomFieldFilter(django_filters.Filter): if value is None or not value.strip(): return queryset - # Selection fields get special treatment (values must be integers) - if self.cf_type == CustomFieldTypeChoices.TYPE_SELECT: - try: - # Treat 0 as None - if int(value) == 0: - return queryset.exclude( - custom_field_values__field__name=self.field_name, - ) - # Match on exact CustomFieldChoice PK - else: - return queryset.filter( - custom_field_values__field__name=self.field_name, - custom_field_values__serialized_value=value, - ) - except ValueError: - return queryset.none() - # Apply the assigned filter logic (exact or loose) - if (self.cf_type == CustomFieldTypeChoices.TYPE_BOOLEAN or - self.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT): - queryset = queryset.filter( - custom_field_values__field__name=self.field_name, - custom_field_values__serialized_value=value - ) + if ( + self.custom_field.type in EXACT_FILTER_TYPES or + self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT + ): + kwargs = {f'custom_field_data__{self.field_name}': value} else: - queryset = queryset.filter( - custom_field_values__field__name=self.field_name, - custom_field_values__serialized_value__icontains=value - ) + kwargs = {f'custom_field_data__{self.field_name}__icontains': value} - return queryset + return queryset.filter(**kwargs) class CustomFieldFilterSet(django_filters.FilterSet): """ Dynamically add a Filter for each CustomField applicable to the parent model. """ - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From d2b7eb161c090b5fc541813cf6681daf25745dd1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Aug 2020 16:43:40 -0400 Subject: [PATCH 016/291] Cache CustomField assignments for API queries --- netbox/extras/api/customfields.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 393e4545f..52c9a18ab 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,7 +1,6 @@ from datetime import datetime from django.contrib.contenttypes.models import ContentType -from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.fields import CreateOnlyDefault, Field @@ -47,28 +46,33 @@ class CustomFieldDefaultValues: class CustomFieldsDataField(Field): - def to_representation(self, obj): - content_type = ContentType.objects.get_for_model(self.parent.Meta.model) - custom_fields = CustomField.objects.filter(obj_type=content_type) + def _get_custom_fields(self): + """ + Cache CustomFields assigned to this model to avoid redundant database queries + """ + if not hasattr(self, '_custom_fields'): + content_type = ContentType.objects.get_for_model(self.parent.Meta.model) + self._custom_fields = CustomField.objects.filter(obj_type=content_type) + return self._custom_fields - return {cf.name: obj.get(cf.name) for cf in custom_fields} + def to_representation(self, obj): + return { + cf.name: obj.get(cf.name) for cf in self._get_custom_fields() + } def to_internal_value(self, data): # If updating an existing instance, start with existing custom_field_data if self.parent.instance: data = {**self.parent.instance.custom_field_data, **data} - content_type = ContentType.objects.get_for_model(self.parent.Meta.model) - custom_fields = { - field.name: field for field in CustomField.objects.filter(obj_type=content_type) - } + custom_fields = {field.name: field for field in self._get_custom_fields()} for field_name, value in data.items(): try: cf = custom_fields[field_name] except KeyError: - raise ValidationError(f"Invalid custom field for {content_type} objects: {field_name}") + raise ValidationError(f"Invalid custom field name: {field_name}") # Data validation if value not in [None, '']: From bde25e69f8284a739074fb3dfd3bdbfe3e789798 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Aug 2020 14:36:45 -0400 Subject: [PATCH 017/291] Add CustomFieldsDataFieldInspector for OpenAPI spec --- netbox/netbox/settings.py | 1 + netbox/utilities/custom_inspectors.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 92f39cf44..7bcc806d7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -484,6 +484,7 @@ REST_FRAMEWORK = { SWAGGER_SETTINGS = { 'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema', 'DEFAULT_FIELD_INSPECTORS': [ + 'utilities.custom_inspectors.CustomFieldsDataFieldInspector', 'utilities.custom_inspectors.JSONFieldInspector', 'utilities.custom_inspectors.NullableBooleanFieldInspector', 'utilities.custom_inspectors.SerializedPKRelatedFieldInspector', diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index e013bf82a..bee2f7d92 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -5,6 +5,7 @@ from drf_yasg.utils import get_serializer_ref_name from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField +from extras.api.customfields import CustomFieldsDataField from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer @@ -60,6 +61,17 @@ class NullableBooleanFieldInspector(FieldInspector): return result +class CustomFieldsDataFieldInspector(FieldInspector): + + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + + if isinstance(field, CustomFieldsDataField) and swagger_object_type == openapi.Schema: + return SwaggerType(type=openapi.TYPE_OBJECT) + + return NotHandled + + class JSONFieldInspector(FieldInspector): """Required because by default, Swagger sees a JSONField as a string and not dict """ From 53e09a924c0d8331391ed02ea41aaca91c46ed2f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Aug 2020 15:04:22 -0400 Subject: [PATCH 018/291] Restore and rename CustomChoiceFieldInspector --- netbox/netbox/settings.py | 1 + netbox/utilities/custom_inspectors.py | 37 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7bcc806d7..66aabfcf5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -487,6 +487,7 @@ SWAGGER_SETTINGS = { 'utilities.custom_inspectors.CustomFieldsDataFieldInspector', 'utilities.custom_inspectors.JSONFieldInspector', 'utilities.custom_inspectors.NullableBooleanFieldInspector', + 'utilities.custom_inspectors.ChoiceFieldInspector', 'utilities.custom_inspectors.SerializedPKRelatedFieldInspector', 'drf_yasg.inspectors.CamelCaseJSONFilter', 'drf_yasg.inspectors.ReferencingSerializerInspector', diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index bee2f7d92..1d5c9c0a0 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -49,6 +49,43 @@ class SerializedPKRelatedFieldInspector(FieldInspector): return NotHandled +class ChoiceFieldInspector(FieldInspector): + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + # this returns a callable which extracts title, description and other stuff + # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types + SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + + if isinstance(field, ChoiceField): + choices = field._choices + choice_value = list(choices.keys()) + choice_label = list(choices.values()) + value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value) + + if set([None] + choice_value) == {None, True, False}: + # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be + # differentiated since they each have subtly different values in their choice keys. + # - subdevice_role and connection_status are booleans, although subdevice_role includes None + # - face is an integer set {0, 1} which is easily confused with {False, True} + schema_type = openapi.TYPE_STRING + if all(type(x) == bool for x in [c for c in choice_value if c is not None]): + schema_type = openapi.TYPE_BOOLEAN + value_schema = openapi.Schema(type=schema_type, enum=choice_value) + value_schema['x-nullable'] = True + + if all(type(x) == int for x in [c for c in choice_value if c is not None]): + # Change value_schema for IPAddressFamilyChoices, RackWidthChoices + value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value) + + schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={ + "label": openapi.Schema(type=openapi.TYPE_STRING, enum=choice_label), + "value": value_schema + }) + + return schema + + return NotHandled + + class NullableBooleanFieldInspector(FieldInspector): def process_result(self, result, method_name, obj, **kwargs): From 08c492f1f4bee4b1db806d0c10c41e70347e415b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Sep 2020 16:09:05 -0400 Subject: [PATCH 019/291] Merge develop into develop-2.10 --- docs/installation/3-netbox.md | 2 +- docs/installation/upgrading.md | 11 ++- docs/models/dcim/interface.md | 2 +- docs/models/ipam/ipaddress.md | 1 + docs/plugins/development.md | 3 + docs/release-notes/version-2.9.md | 65 ++++++++++++++- mkdocs.yml | 1 + netbox/dcim/choices.py | 6 ++ netbox/dcim/elevations.py | 18 ++-- netbox/dcim/forms.py | 26 ++++-- .../migrations/0115_rackreservation_order.py | 17 ++++ netbox/dcim/models/device_components.py | 16 ++-- netbox/dcim/models/devices.py | 2 +- netbox/dcim/models/racks.py | 2 +- netbox/dcim/tables.py | 71 +++++++++++++--- netbox/dcim/views.py | 9 +- netbox/extras/api/customfields.py | 2 +- netbox/extras/api/views.py | 1 + netbox/extras/filters.py | 10 ++- netbox/extras/tests/test_filters.py | 82 ++++++++++++++++++- netbox/ipam/api/views.py | 4 +- netbox/ipam/choices.py | 2 + netbox/ipam/models.py | 9 +- netbox/ipam/tables.py | 28 ++++--- netbox/ipam/views.py | 6 +- netbox/netbox/settings.py | 3 +- netbox/templates/500.html | 5 +- .../templates/dcim/device_component_edit.html | 16 ++++ netbox/templates/dcim/inc/consoleport.html | 2 +- .../templates/dcim/inc/consoleserverport.html | 2 +- netbox/templates/dcim/inc/devicebay.html | 2 +- netbox/templates/dcim/inc/poweroutlet.html | 2 +- netbox/templates/dcim/inc/powerport.html | 2 +- netbox/templates/dcim/interface_edit.html | 15 ++++ netbox/templates/inc/plugin_menu_items.html | 28 ++++--- .../ipam/inc/ipadress_edit_header.html | 2 +- netbox/templates/utilities/obj_edit.html | 4 +- .../virtualization/inc/vminterface.html | 8 +- .../virtualmachine_component_add.html | 2 +- .../virtualization/vminterface_edit.html | 20 +++++ netbox/users/views.py | 26 ++++-- netbox/utilities/tables.py | 2 +- netbox/utilities/views.py | 4 + netbox/virtualization/forms.py | 29 +++---- netbox/virtualization/models.py | 6 +- netbox/virtualization/tables.py | 5 +- 46 files changed, 464 insertions(+), 117 deletions(-) create mode 100644 netbox/dcim/migrations/0115_rackreservation_order.py create mode 100644 netbox/templates/dcim/device_component_edit.html diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 05f6d825e..235e39a8f 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies. Before continuing with either platform, update pip (Python's package management tool) to its latest release: ```no-highlight -# pip install --upgrade pip +# pip3 install --upgrade pip ``` ## Download NetBox diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 807b9b1e6..34274342c 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -4,8 +4,15 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect. -!!! note - Beginning with version 2.8, NetBox requires Python 3.6 or later. +## Update Dependencies to Required Versions + +NetBox v2.9.0 and later requires the following: + +| Dependency | Minimum Version | +|------------|-----------------| +| Python | 3.6 | +| PostgreSQL | 9.6 | +| Redis | 4.0 | ## Install the Latest Code diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index be43ac2a6..756e320af 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -4,7 +4,7 @@ Interfaces in NetBox represent network interfaces used to exchange data with con Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. -Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. Like all virtual interfaces, LAG interfaces cannot be connected physically. +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. IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) diff --git a/docs/models/ipam/ipaddress.md b/docs/models/ipam/ipaddress.md index 04ac417db..1ea613997 100644 --- a/docs/models/ipam/ipaddress.md +++ b/docs/models/ipam/ipaddress.md @@ -10,6 +10,7 @@ Each IP address can also be assigned an operational status and a functional role * Reserved * Deprecated * DHCP +* SLAAC (IPv6 Stateless Address Autoconfiguration) Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include: diff --git a/docs/plugins/development.md b/docs/plugins/development.md index b704ad7fc..f4db3c84d 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes: * `color` - One of the choices provided by `ButtonColorChoices` (optional) * `permissions` - A list of permissions required to display this button (optional) +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. + ## Extending Core Templates Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index cc38fdb5e..a3d500094 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,7 +1,69 @@ # NetBox v2.9 +## v2.9.3 (2020-09-04) + +### Enhancements + +* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view +* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component +* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments +* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types + +### Bug Fixes + +* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable +* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices +* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master +* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI +* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field +* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table +* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets +* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component +* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections +* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences +* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list + +--- + +## v2.9.2 (2020-08-27) + +### Enhancements + +* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables +* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list + +### Bug Fixes + +* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times +* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint +* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM +* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines +* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses +* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface +* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status +* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import +* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage +* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view +* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices +* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces + +--- + +## v2.9.1 (2020-08-22) + +### Enhancements + +* [#4540](https://github.com/netbox-community/netbox/issues/4540) - Add IP address status type for SLAAC +* [#4814](https://github.com/netbox-community/netbox/issues/4814) - Allow nested LAG interfaces +* [#4991](https://github.com/netbox-community/netbox/issues/4991) - Add Python and NetBox versions to error page +* [#5033](https://github.com/netbox-community/netbox/issues/5033) - Support backward compatibility for `REMOTE_AUTH_BACKEND` configuration parameter + +--- + ## v2.9.0 (2020-08-21) +**Note:** Redis 4.0 or later is required for this release. + ### New Features #### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554)) @@ -56,7 +118,8 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip ### Configuration Changes -* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) +* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved. +* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. ### REST API Changes diff --git a/mkdocs.yml b/mkdocs.yml index bd8fc780d..b3f8ae1bc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - User Preferences: 'development/user-preferences.md' - Release Checklist: 'development/release-checklist.md' - Release Notes: + - Version 2.9: 'release-notes/version-2.9.md' - Version 2.8: 'release-notes/version-2.8.md' - Version 2.7: 'release-notes/version-2.7.md' - Version 2.6: 'release-notes/version-2.6.md' diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index dc12e686e..fa4f81792 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet): class PortTypeChoices(ChoiceSet): TYPE_8P8C = '8p8c' + TYPE_8P6C = '8p6c' + TYPE_8P4C = '8p4c' + TYPE_8P2C = '8p2c' TYPE_110_PUNCH = '110-punch' TYPE_BNC = 'bnc' TYPE_MRJ21 = 'mrj21' @@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet): 'Copper', ( (TYPE_8P8C, '8P8C'), + (TYPE_8P6C, '8P6C'), + (TYPE_8P4C, '8P4C'), + (TYPE_8P2C, '8P2C'), (TYPE_110_PUNCH, '110 Punch'), (TYPE_BNC, 'BNC'), (TYPE_MRJ21, 'MRJ21'), diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py index cef95a7b6..93c44f087 100644 --- a/netbox/dcim/elevations.py +++ b/netbox/dcim/elevations.py @@ -94,8 +94,12 @@ class RackElevationSVG: # Embed front device type image if one exists if self.include_images and device.device_type.front_image: - url = '{}{}'.format(self.base_url, device.device_type.front_image.url) - image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image = drawing.image( + href=device.device_type.front_image.url, + insert=start, + size=end, + class_='device-image' + ) image.fit(scale='slice') link.add(image) @@ -107,8 +111,12 @@ class RackElevationSVG: # Embed rear device type image if one exists if self.include_images and device.device_type.rear_image: - url = device.device_type.rear_image.url - image = drawing.image(href=url, insert=start, size=end, class_='device-image') + image = drawing.image( + href=device.device_type.rear_image.url, + insert=start, + size=end, + class_='device-image' + ) image.fit(scale='slice') drawing.add(image) @@ -141,7 +149,7 @@ class RackElevationSVG: unit_cursor = 0 for u in elevation: o = other[unit_cursor] - if not u['device'] and o['device']: + 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) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6dd5cb6bf..43f77de51 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1811,7 +1811,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): nat_inside__assigned_object_id__in=interface_ids ).prefetch_related('assigned_object') if nat_ips: - ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips] + ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices @@ -2317,7 +2317,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2390,7 +2390,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', + 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2479,7 +2479,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags', + 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2686,7 +2686,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): device_query = Q(device=device) if device.virtual_chassis: device_query |= Q(device__virtual_chassis=device.virtual_chassis) - self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG) + self.fields['lag'].queryset = Interface.objects.filter( + device_query, + type=InterfaceTypeChoices.TYPE_LAG + ).exclude(pk=self.instance.pk) # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) @@ -2876,17 +2879,22 @@ class InterfaceCSVForm(CSVModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device (or VC master) + # Limit LAG choices to interfaces belonging to this device (or virtual chassis) device = None if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: pass - - if device: + if device and device.virtual_chassis: self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG + Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), + type=InterfaceTypeChoices.TYPE_LAG + ) + elif device: + self.fields['lag'].queryset = Interface.objects.filter( + device=device, + type=InterfaceTypeChoices.TYPE_LAG ) else: self.fields['lag'].queryset = Interface.objects.none() diff --git a/netbox/dcim/migrations/0115_rackreservation_order.py b/netbox/dcim/migrations/0115_rackreservation_order.py new file mode 100644 index 000000000..594f6b9a4 --- /dev/null +++ b/netbox/dcim/migrations/0115_rackreservation_order.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-08-24 16:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0114_update_jsonfield'), + ] + + operations = [ + migrations.AlterModelOptions( + name='rackreservation', + options={'ordering': ['created', 'pk']}, + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9bd7cdc8b..4d79f3434 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -702,18 +702,12 @@ class Interface(CableTermination, ComponentModel, BaseInterface): }) # A virtual interface cannot have a parent LAG - if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: - raise ValidationError({ - 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display()) - }) + if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: + raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) - # Only a LAG can have LAG members - if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists(): - raise ValidationError({ - 'type': "Cannot change interface type; it has LAG members ({}).".format( - ", ".join([iface.name for iface in self.member_interfaces.all()]) - ) - }) + # A LAG interface cannot be its own parent + if self.pk and self.lag_id == self.pk: + raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8bb56101e..c152e7c04 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -633,7 +633,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # of the uniqueness constraint without manual intervention. - if self.name and self.tenant is None: + if self.name and hasattr(self, 'site') and self.tenant is None: if Device.objects.exclude(pk=self.pk).filter( name=self.name, site=self.site, diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3169272b4..6c5ab08b9 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -600,7 +600,7 @@ class RackReservation(ChangeLoggedModel): csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] class Meta: - ordering = ['created'] + ordering = ['created', 'pk'] def __str__(self): return "Reservation for rack {}".format(self.rack) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index e48eaedba..371eff9db 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """ {% endfor %} """ +CONNECTION_STATUS = """ +{{ record.get_connection_status_display }} +""" + # # Regions @@ -706,34 +710,48 @@ class DeviceComponentTable(BaseTable): class ConsolePortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleport_list' + ) class Meta(DeviceComponentTable.Meta): model = ConsolePort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') class ConsoleServerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleserverport_list' + ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') class PowerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:powerport_list' + ) class Meta(DeviceComponentTable.Meta): model = PowerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:poweroutlet_list' + ) class Meta(DeviceComponentTable.Meta): model = PowerOutlet - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -753,12 +771,15 @@ class BaseInterfaceTable(BaseTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): + tags = TagColumn( + url_name='dcim:interface_list' + ) class Meta(DeviceComponentTable.Meta): model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') @@ -767,18 +788,26 @@ class FrontPortTable(DeviceComponentTable): rear_port_position = tables.Column( verbose_name='Position' ) + tags = TagColumn( + url_name='dcim:frontport_list' + ) class Meta(DeviceComponentTable.Meta): model = FrontPort - fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') class RearPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:rearport_list' + ) class Meta(DeviceComponentTable.Meta): model = RearPort - fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable') + fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') @@ -786,10 +815,13 @@ class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True ) + tags = TagColumn( + url_name='dcim:devicebay_list' + ) class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description') + fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') @@ -798,12 +830,16 @@ class InventoryItemTable(DeviceComponentTable): linkify=True ) discovered = BooleanColumn() + tags = TagColumn( + url_name='dcim:inventoryitem_list' + ) + cable = None # Override DeviceComponentTable class Meta(DeviceComponentTable.Meta): model = InventoryItem fields = ( 'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'discovered', + 'discovered', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') @@ -876,15 +912,20 @@ class ConsoleConnectionTable(BaseTable): verbose_name='Console Server' ) connected_endpoint = tables.Column( + linkify=True, verbose_name='Port' ) device = tables.Column( linkify=True ) name = tables.Column( + linkify=True, verbose_name='Console Port' ) - connection_status = BooleanColumn() + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = ConsolePort @@ -901,14 +942,20 @@ class PowerConnectionTable(BaseTable): ) outlet = tables.Column( accessor=Accessor('_connected_poweroutlet'), + linkify=True, verbose_name='Outlet' ) device = tables.Column( linkify=True ) name = tables.Column( + linkify=True, verbose_name='Power Port' ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = PowerPort @@ -940,6 +987,10 @@ class InterfaceConnectionTable(BaseTable): args=[Accessor('_connected_interface__pk')], verbose_name='Interface B' ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = Interface diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c3cfa105f..f9a04aede 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1030,7 +1030,7 @@ class DeviceView(ObjectView): ) # Interfaces - interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related( + interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', @@ -1228,6 +1228,7 @@ class ConsolePortCreateView(ComponentCreateView): class ConsolePortEditView(ObjectEditView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm + template_name = 'dcim/device_component_edit.html' class ConsolePortDeleteView(ObjectDeleteView): @@ -1287,6 +1288,7 @@ class ConsoleServerPortCreateView(ComponentCreateView): class ConsoleServerPortEditView(ObjectEditView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm + template_name = 'dcim/device_component_edit.html' class ConsoleServerPortDeleteView(ObjectDeleteView): @@ -1346,6 +1348,7 @@ class PowerPortCreateView(ComponentCreateView): class PowerPortEditView(ObjectEditView): queryset = PowerPort.objects.all() model_form = forms.PowerPortForm + template_name = 'dcim/device_component_edit.html' class PowerPortDeleteView(ObjectDeleteView): @@ -1405,6 +1408,7 @@ class PowerOutletCreateView(ComponentCreateView): class PowerOutletEditView(ObjectEditView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm + template_name = 'dcim/device_component_edit.html' class PowerOutletDeleteView(ObjectDeleteView): @@ -1556,6 +1560,7 @@ class FrontPortCreateView(ComponentCreateView): class FrontPortEditView(ObjectEditView): queryset = FrontPort.objects.all() model_form = forms.FrontPortForm + template_name = 'dcim/device_component_edit.html' class FrontPortDeleteView(ObjectDeleteView): @@ -1615,6 +1620,7 @@ class RearPortCreateView(ComponentCreateView): class RearPortEditView(ObjectEditView): queryset = RearPort.objects.all() model_form = forms.RearPortForm + template_name = 'dcim/device_component_edit.html' class RearPortDeleteView(ObjectDeleteView): @@ -1674,6 +1680,7 @@ class DeviceBayCreateView(ComponentCreateView): class DeviceBayEditView(ObjectEditView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm + template_name = 'dcim/device_component_edit.html' class DeviceBayDeleteView(ObjectDeleteView): diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5ef983977..f096fb4a6 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): instance.custom_fields = {} for field in custom_fields: value = instance.cf.get(field.name) - if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None: + if field.type == CustomFieldTypeChoices.TYPE_SELECT and value: instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data else: instance.custom_fields[field.name] = value diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 5fa26a0d7..5a7cfa482 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -129,6 +129,7 @@ class ImageAttachmentViewSet(ModelViewSet): metadata_class = ContentTypeMetadata queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer + filterset_class = filters.ImageAttachmentFilterSet # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 73811c063..b37701d9d 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, ExportTemplate, ObjectChange, JobResult, Tag +from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag __all__ = ( @@ -16,6 +16,7 @@ __all__ = ( 'CustomFieldFilter', 'CustomFieldFilterSet', 'ExportTemplateFilterSet', + 'ImageAttachmentFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', @@ -96,6 +97,13 @@ class ExportTemplateFilterSet(BaseFilterSet): fields = ['id', 'content_type', 'name'] +class ImageAttachmentFilterSet(BaseFilterSet): + + class Meta: + model = ImageAttachment + fields = ['id', 'content_type', 'object_id', 'name'] + + class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index cc702f07b..e96293b20 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -1,9 +1,9 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Rack, Region, Site from extras.filters import * -from extras.models import ConfigContext, ExportTemplate, Tag +from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -37,6 +37,84 @@ class ExportTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class ImageAttachmentTestCase(TestCase): + queryset = ImageAttachment.objects.all() + filterset = ImageAttachmentFilterSet + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get(app_label='dcim', model='site') + rack_ct = ContentType.objects.get(app_label='dcim', model='rack') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + ) + Rack.objects.bulk_create(racks) + + image_attachments = ( + ImageAttachment( + content_type=site_ct, + object_id=sites[0].pk, + name='Image Attachment 1', + image='http://example.com/image1.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=site_ct, + object_id=sites[1].pk, + name='Image Attachment 2', + image='http://example.com/image2.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=rack_ct, + object_id=racks[0].pk, + name='Image Attachment 3', + image='http://example.com/image3.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=rack_ct, + object_id=racks[1].pk, + name='Image Attachment 4', + image='http://example.com/image4.png', + image_height=100, + image_width=100 + ) + ) + ImageAttachment.objects.bulk_create(image_attachments) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Image Attachment 1', 'Image Attachment 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type(self): + params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type_and_object_id(self): + params = { + 'content_type': ContentType.objects.get(app_label='dcim', model='site').pk, + 'object_id': [Site.objects.first().pk], + } + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ConfigContextTestCase(TestCase): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0d273e4d8..dd0731bb8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -88,7 +88,7 @@ class PrefixViewSet(CustomFieldModelViewSet): return super().get_serializer_class() @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)}) - @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)}) + @swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)}) @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def available_prefixes(self, request, pk=None): @@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', + 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object' ) serializer_class = serializers.IPAddressSerializer filterset_class = filters.IPAddressFilterSet diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 68fdfd9df..f3ff19ddc 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -41,12 +41,14 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' STATUS_DHCP = 'dhcp' + STATUS_SLAAC = 'slaac' CHOICES = ( (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), (STATUS_DHCP, 'DHCP'), + (STATUS_SLAAC, 'SLAAC'), ) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 58dd96089..832e09330 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -669,6 +669,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): 'reserved': 'info', 'deprecated': 'danger', 'dhcp': 'success', + 'slaac': 'success', } ROLE_CLASS_MAP = { @@ -745,12 +746,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an " f"interface" }) - elif self.interface.virtual_machine != vm: + elif self.assigned_object.virtual_machine != vm: raise ValidationError({ 'vminterface': f"IP address is primary for virtual machine {vm} but assigned to " f"{self.assigned_object.virtual_machine} ({self.assigned_object})" }) + # Validate IP status selection + if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: + raise ValidationError({ + 'status': "Only IPv6 addresses can be assigned SLAAC status" + }) + def save(self, *args, **kwargs): # Force dns_name to lowercase diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 5a4e2c133..d7a64f7db 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -67,11 +67,7 @@ IPADDRESS_LINK = """ """ IPADDRESS_ASSIGN_LINK = """ -{% if request.GET %} - {{ record }} -{% else %} - {{ record }} -{% endif %} +{{ record }} """ VRF_LINK = """ @@ -103,7 +99,7 @@ VLAN_LINK = """ """ VLAN_PREFIXES = """ -{% for prefix in record.prefixes.unrestricted %} +{% for prefix in record.prefixes.all %} {{ prefix }}{% if not forloop.last %}
{% endif %} {% empty %} — @@ -387,15 +383,23 @@ class IPAddressTable(BaseTable): tenant = tables.TemplateColumn( template_code=TENANT_LINK ) - assigned = tables.BooleanColumn( - accessor='assigned_object_id', - verbose_name='Assigned' + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Interface' + ) + assigned_object_parent = tables.Column( + accessor='assigned_object__parent', + linkify=True, + orderable=False, + verbose_name='Interface Parent' ) class Meta(BaseTable.Meta): model = IPAddress fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name', + 'description', ) row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', @@ -411,6 +415,10 @@ class IPAddressDetailTable(IPAddressTable): tenant = tables.TemplateColumn( template_code=COL_TENANT ) + assigned = tables.BooleanColumn( + accessor='assigned_object_id', + verbose_name='Assigned' + ) tags = TagColumn( url_name='ipam:ipaddress_list' ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8ea33764c..1f0e2607e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView): class IPAddressListView(ObjectListView): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside' + 'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object' ) filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm @@ -582,7 +582,7 @@ class IPAddressAssignView(ObjectView): def dispatch(self, request, *args, **kwargs): # Redirect user if an interface has not been provided - if 'interface' not in request.GET: + if 'interface' not in request.GET and 'vminterface' not in request.GET: return redirect('ipam:ipaddress_add') return super().dispatch(request, *args, **kwargs) @@ -609,7 +609,7 @@ class IPAddressAssignView(ObjectView): return render(request, 'ipam/ipaddress_assign.html', { 'form': form, 'table': table, - 'return_url': request.GET.get('return_url', ''), + 'return_url': request.GET.get('return_url'), }) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d49e41b76..bd247070f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.9.1-dev' +VERSION = '2.9.4-dev' # Hostname HOSTNAME = platform.node() @@ -132,7 +132,6 @@ if RELEASE_CHECK_URL: if RELEASE_CHECK_TIMEOUT < 3600: raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") - # # Database # diff --git a/netbox/templates/500.html b/netbox/templates/500.html index bd59b7233..61115cbab 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -31,7 +31,10 @@ The complete exception is provided below:

{{ exception }}
-{{ error }}
+{{ error }} + +Python version: {{ python_version }} +NetBox version: {{ netbox_version }}

If further assistance is required, please post to the NetBox mailing list.

diff --git a/netbox/templates/dcim/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html new file mode 100644 index 000000000..e0f1a2326 --- /dev/null +++ b/netbox/templates/dcim/device_component_edit.html @@ -0,0 +1,16 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form_fields %} + {% if form.instance.device %} +
+ + +
+ {% endif %} + {% render_form form %} +{% endblock %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index dc2111b8a..6fa5e8b91 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -66,7 +66,7 @@ {% endif %} {% if perms.dcim.change_consoleport %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index dcf168ae7..fca1fa5f4 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -68,7 +68,7 @@ {% endif %} {% if perms.dcim.change_consoleserverport %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index ee6a66d8f..bde7b8641 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -52,7 +52,7 @@ {% endif %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index d9a77d647..5800f4b48 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -81,7 +81,7 @@ {% endif %} {% if perms.dcim.change_poweroutlet %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 58eed145a..b30fc8456 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -78,7 +78,7 @@ {% endif %} {% if perms.dcim.change_powerport %} - + {% endif %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index eaffe2bca..7a5c99905 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -5,6 +5,16 @@
Interface
+ {% if form.instance.device %} +
+ + +
+ {% endif %} {% render_field form.name %} {% render_field form.label %} {% render_field form.type %} @@ -14,6 +24,11 @@ {% render_field form.mtu %} {% render_field form.mgmt_only %} {% render_field form.description %} +
+
+
+
802.1Q Switching
+
{% render_field form.mode %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %} diff --git a/netbox/templates/inc/plugin_menu_items.html b/netbox/templates/inc/plugin_menu_items.html index 0df4a5e8a..3d9a46a52 100644 --- a/netbox/templates/inc/plugin_menu_items.html +++ b/netbox/templates/inc/plugin_menu_items.html @@ -5,18 +5,22 @@ {% for section_name, menu_items in registry.plugin_menu_items.items %} {% for menu_item in menu_items %} - - {% if menu_item.buttons %} -
- {% for button in menu_item.buttons %} - {% if not button.permissions or request.user|has_perms:button.permissions %} - - {% endif %} - {% endfor %} -
- {% endif %} - {{ menu_item.link_text }} - + {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %} +
  • + {% if menu_item.buttons %} +
    + {% for button in menu_item.buttons %} + {% if not button.permissions or request.user|has_perms:button.permissions %} + + {% endif %} + {% endfor %} +
    + {% endif %} + {{ menu_item.link_text }} +
  • + {% else %} +
  • {{ menu_item.link_text }}
  • + {% endif %} {% endfor %} {% if not forloop.last %}
  • diff --git a/netbox/templates/ipam/inc/ipadress_edit_header.html b/netbox/templates/ipam/inc/ipadress_edit_header.html index b8ec3878a..ed9692eea 100644 --- a/netbox/templates/ipam/inc/ipadress_edit_header.html +++ b/netbox/templates/ipam/inc/ipadress_edit_header.html @@ -4,7 +4,7 @@ - {% if 'interface' in request.GET %} + {% if 'interface' in request.GET or 'vminterface' in request.GET %} diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 5230b2594..0bd051161 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -31,7 +31,9 @@
    {{ obj_type|capfirst }}
    - {% render_form form %} + {% block form_fields %} + {% render_form form %} + {% endblock %}
    {% endblock %} diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html index 0f672b729..93efafb5a 100644 --- a/netbox/templates/virtualization/inc/vminterface.html +++ b/netbox/templates/virtualization/inc/vminterface.html @@ -2,7 +2,7 @@ {# Checkbox #} - {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} @@ -43,12 +43,12 @@ {% endif %} - {% if perms.virtualization.change_interface %} + {% if perms.virtualization.change_vminterface %} {% endif %} - {% if perms.virtualization.delete_interface %} + {% if perms.virtualization.delete_vminterface %} @@ -60,7 +60,7 @@ {% if ipaddresses %} {# Placeholder #} - {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html index aafefffa1..11b120ee0 100644 --- a/netbox/templates/virtualization/virtualmachine_component_add.html +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -2,7 +2,7 @@ {% load helpers %} {% load form_helpers %} -{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} +{% block title %}Create {{ component_type }}{% endblock %} {% block content %}
    diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index 6b0313284..12018ba5d 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -5,14 +5,34 @@
    Interface
    + {% if form.instance.virtual_machine %} +
    + + +
    + {% endif %} {% render_field form.name %} {% render_field form.enabled %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} +
    +
    +
    +
    802.1Q Switching
    +
    {% render_field form.mode %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %} +
    +
    +
    +
    Tags
    +
    {% render_field form.tags %}
    diff --git a/netbox/users/views.py b/netbox/users/views.py index 755232444..46221f649 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -38,6 +38,10 @@ class LoginView(View): def get(self, request): form = LoginForm(request) + if request.user.is_authenticated: + logger = logging.getLogger('netbox.auth.login') + return self.redirect_to_next(request, logger) + return render(request, self.template_name, { 'form': form, }) @@ -49,12 +53,6 @@ class LoginView(View): if form.is_valid(): logger.debug("Login form validation was successful") - # Determine where to direct user after successful login - redirect_to = request.POST.get('next', reverse('home')) - if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): - logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") - redirect_to = reverse('home') - # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # last_login time upon authentication. if settings.MAINTENANCE_MODE: @@ -66,8 +64,7 @@ class LoginView(View): logger.info(f"User {request.user} successfully authenticated") messages.info(request, "Logged in as {}.".format(request.user)) - logger.debug(f"Redirecting user to {redirect_to}") - return HttpResponseRedirect(redirect_to) + return self.redirect_to_next(request, logger) else: logger.debug("Login form validation failed") @@ -76,6 +73,19 @@ class LoginView(View): 'form': form, }) + def redirect_to_next(self, request, logger): + if request.method == "POST": + redirect_to = request.POST.get('next', reverse('home')) + else: + redirect_to = request.GET.get('next', reverse('home')) + + if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): + logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") + redirect_to = reverse('home') + + logger.debug(f"Redirecting user to {redirect_to}") + return HttpResponseRedirect(redirect_to) + class LogoutView(View): """ diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 0144ea2d1..6df6b2e26 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -44,7 +44,7 @@ class BaseTable(tables.Table): self.columns.show(name) else: self.columns.hide(name) - self.sequence = columns + self.sequence = [c for c in columns if c in self.base_columns] # Always include PK and actions column, if defined on the table if pk: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c7db2f649..079068648 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,8 +1,10 @@ import logging +import platform import re import sys from copy import deepcopy +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType @@ -1421,6 +1423,8 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): type_, error, traceback = sys.exc_info() return HttpResponseServerError(template.render({ + 'python_version': platform.python_version(), + 'netbox_version': settings.VERSION, 'exception': str(type_), 'error': error, })) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index a64a0a7d8..5d002decc 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from dcim.choices import InterfaceModeChoices @@ -325,28 +326,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: ip_choices = [(None, '---------')] + + # Gather PKs of all interfaces belonging to this VM + interface_ids = self.instance.interfaces.values_list('pk', flat=True) + # Collect interface IPs - interface_ips = IPAddress.objects.prefetch_related('interface').filter( + interface_ips = IPAddress.objects.filter( address__family=family, - vminterface__in=self.instance.interfaces.values_list('id', flat=True) + assigned_object_type=ContentType.objects.get_for_model(VMInterface), + assigned_object_id__in=interface_ids ) if interface_ips: - ip_choices.append( - ('Interface IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips - ]) - ) + ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] + ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( address__family=family, - nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True) + nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface), + nat_inside__assigned_object_id__in=interface_ids ) if nat_ips: - ip_choices.append( - ('NAT IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips - ]) - ) + ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] + ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices else: @@ -683,7 +684,7 @@ class VMInterfaceCSVForm(CSVModelForm): return self.cleaned_data['enabled'] -class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm): +class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index fb61c5b9e..0845fac7e 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -335,13 +335,13 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): for field in ['primary_ip4', 'primary_ip6']: ip = getattr(self, field) if ip is not None: - if ip.interface in interfaces: + if ip.assigned_object in interfaces: pass - elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces: + elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces: pass else: raise ValidationError({ - field: "The specified IP address ({}) is not assigned to this VM.".format(ip), + field: f"The specified IP address ({ip}) is not assigned to this VM.", }) def to_csv(self): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 039934d70..5f5b9326d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -154,11 +154,14 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) + tags = TagColumn( + url_name='virtualization:vminterface_list' + ) class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'ip_addresses', + 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description') From 6694ec78bc1723c2e339f53a1e26d936f541d5b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Sep 2020 13:36:36 -0400 Subject: [PATCH 020/291] Implement support for bulk deletion of objects via a single REST API request --- netbox/utilities/api.py | 56 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index cc9789161..acafd81bd 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -8,13 +8,13 @@ from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDo from django.db import transaction from django.db.models import ManyToManyField, ProtectedError from django.urls import reverse -from rest_framework import serializers +from rest_framework import mixins, serializers, status from rest_framework.exceptions import APIException, ValidationError from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.response import Response from rest_framework.routers import DefaultRouter -from rest_framework.viewsets import ModelViewSet as _ModelViewSet +from rest_framework.viewsets import GenericViewSet from .utils import dict_to_filter_params, dynamic_import @@ -291,11 +291,53 @@ class WritableNestedSerializer(serializers.ModelSerializer): ) +class BulkDeleteSerializer(serializers.Serializer): + id = serializers.IntegerField() + + +# +# Mixins +# + +class BulkDestroyModelMixin: + """ + Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one + or more JSON objects, each specifying the numeric ID of an object to be deleted. For example: + + DELETE /api/dcim/sites/ + [ + {"id": 123}, + {"id": 456} + ] + """ + def bulk_destroy(self, request): + serializer = BulkDeleteSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + + pk_list = [o['id'] for o in serializer.data] + qs = self.get_queryset().filter(pk__in=pk_list) + + self.perform_bulk_destroy(qs) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def perform_bulk_destroy(self, objects): + with transaction.atomic(): + for obj in objects: + self.perform_destroy(obj) + + # # Viewsets # -class ModelViewSet(_ModelViewSet): +class ModelViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + BulkDestroyModelMixin, + GenericViewSet): """ Accept either a single object or a list of objects to create. """ @@ -408,6 +450,14 @@ class ModelViewSet(_ModelViewSet): class OrderedDefaultRouter(DefaultRouter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Extend the list view mappings to support the DELETE operation + self.routes[0].mapping.update({ + 'delete': 'bulk_destroy', + }) + def get_api_root_view(self, api_urls=None): """ Wrap DRF's DefaultRouter to return an alphabetized list of endpoints. From eba2ea06ffd58807e854df761884bac50a506e54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Sep 2020 14:36:38 -0400 Subject: [PATCH 021/291] Add test for bulk API deletions --- netbox/utilities/testing/api.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index bf6ebd7ff..2c6c70fea 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -300,6 +300,29 @@ class APIViewTestCases: self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertFalse(self._get_queryset().filter(pk=instance.pk).exists()) + def test_bulk_delete_objects(self): + """ + DELETE a set of objects in a single request. + """ + # Add object-level permission + obj_perm = ObjectPermission( + actions=['delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Target the three most recently created objects to avoid triggering recursive deletions + # (e.g. with MPTT objects) + id_list = self._get_queryset().order_by('-id').values_list('id', flat=True)[:3] + self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk deletion") + data = [{"id": id} for id in id_list] + + initial_count = self._get_queryset().count() + response = self.client.delete(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(self._get_queryset().count(), initial_count - 3) + class APIViewTestCase( GetObjectViewTestCase, ListObjectsViewTestCase, From a7431025676d0b5a0602904b506bf847a05f96a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 15 Sep 2020 15:53:59 -0400 Subject: [PATCH 022/291] Fix serialization of custom_fields for change logging --- netbox/utilities/utils.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 81baadb7a..fbb7830e2 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -91,11 +91,9 @@ def serialize_object(obj, extra=None, exclude=None): json_str = serialize('json', [obj]) data = json.loads(json_str)[0]['fields'] - # Include any custom fields - if hasattr(obj, 'get_custom_fields'): - data['custom_fields'] = { - field: str(value) for field, value in obj.cf.items() - } + # Include custom_field_data as "custom_fields" + if hasattr(obj, 'custom_field_data'): + data['custom_fields'] = data.pop('custom_field_data') # Include any tags. Check for tags cached on the instance; fall back to using the manager. if is_taggable(obj): From 2d56a658b3f79b6eff70fd1ef8a8fcb336d19e99 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Sep 2020 17:03:31 -0400 Subject: [PATCH 023/291] Clean up stale data when a custom field is changed/deleted --- netbox/extras/models/customfields.py | 11 +++++++++++ netbox/extras/signals.py | 27 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 422438539..eac481bef 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -116,6 +116,17 @@ class CustomField(models.Model): def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() + def remove_stale_data(self, content_types): + """ + Delete custom field data which is no longer relevant (either because the CustomField is + no longer assigned to a model, or because it has been deleted). + """ + for ct in content_types: + model = ct.model_class() + for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}): + del(obj.custom_field_data[self.name]) + obj.save() + def clean(self): # Choices can be set only on selection fields if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index e10c41d34..d4e187b5c 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -3,12 +3,14 @@ from datetime import timedelta from cacheops.signals import cache_invalidated, cache_read from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db.models.signals import m2m_changed, pre_delete from django.utils import timezone from django_prometheus.models import model_deletes, model_inserts, model_updates from prometheus_client import Counter from .choices import ObjectChangeActionChoices -from .models import ObjectChange +from .models import CustomField, ObjectChange from .webhooks import enqueue_webhooks @@ -71,6 +73,29 @@ def _handle_deleted_object(request, sender, instance, **kwargs): model_deletes.labels(instance._meta.model_name).inc() +# +# Custom fields +# + +def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs): + """ + Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes. + """ + if action == 'post_remove': + instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set)) + + +def handle_cf_deleted(instance, **kwargs): + """ + Handle the cleanup of old custom field data when a CustomField is deleted. + """ + instance.remove_stale_data(instance.obj_type.all()) + + +m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.obj_type.through) +pre_delete.connect(handle_cf_deleted, sender=CustomField) + + # # Caching # From 4ecd3d23f7f3790d5fba1d894f87e7772145a8bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Sep 2020 12:14:02 -0400 Subject: [PATCH 024/291] Disable bulk deletion of CustomFields in admin UI --- netbox/extras/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 4bd738160..9af9077ac 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -83,13 +83,14 @@ class CustomFieldForm(forms.ModelForm): @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): + actions = None + form = CustomFieldForm list_display = [ 'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description', ] list_filter = [ 'type', 'required', 'obj_type', ] - form = CustomFieldForm def models(self, obj): return ', '.join([ct.name for ct in obj.obj_type.all()]) From 3d2f6c07031d0abbd8c08f304f7b5665ece9106b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Sep 2020 12:26:02 -0400 Subject: [PATCH 025/291] Simplify form field for boolean CustomFields --- netbox/extras/models/customfields.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index eac481bef..bb577fd4b 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -159,15 +159,11 @@ class CustomField(models.Model): elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: choices = ( (None, '---------'), - (1, 'True'), - (0, 'False'), + (True, 'True'), + (False, 'False'), ) - if initial is not None and initial.lower() in ['true', 'yes', '1']: - initial = 1 - elif initial is not None and initial.lower() in ['false', 'no', '0']: - initial = 0 - else: - initial = None + if initial is not None: + initial = bool(initial) field = forms.NullBooleanField( required=required, initial=initial, widget=StaticSelect2(choices=choices) ) From 61cf9030285697f5e1bc70eccb958d0219594784 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Sep 2020 12:39:57 -0400 Subject: [PATCH 026/291] Clean up CustomField admin form --- netbox/extras/admin.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 9af9077ac..a8b4c0eb8 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,6 +2,7 @@ from django import forms from django.contrib import admin from utilities.forms import LaxURLField +from .choices import CustomFieldTypeChoices from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook @@ -80,6 +81,14 @@ class CustomFieldForm(forms.ModelForm): order_content_types(self.fields['obj_type']) + def clean(self): + + # Validate selection choices + if self.cleaned_data['type'] == CustomFieldTypeChoices.TYPE_SELECT and len(self.cleaned_data['choices']) < 2: + raise forms.ValidationError({ + 'choices': 'Selection fields must specify at least two choices.' + }) + @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): @@ -91,6 +100,19 @@ class CustomFieldAdmin(admin.ModelAdmin): list_filter = [ 'type', 'required', 'obj_type', ] + fieldsets = ( + ('Custom Field', { + 'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic') + }), + ('Assignment', { + 'description': 'A custom field must be assigned to one or more object types.', + 'fields': ('obj_type',) + }), + ('Choices', { + 'description': 'A selection field must have two or more choices assigned to it.', + 'fields': ('choices',) + }) + ) def models(self, obj): return ', '.join([ct.name for ct in obj.obj_type.all()]) From 91eca8cac965f9abd0c764b27e622fd9227467b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Sep 2020 13:25:18 -0400 Subject: [PATCH 027/291] Changelog for #4878 --- docs/release-notes/version-2.10.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index bc7c1a1a1..45c192148 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -8,6 +8,7 @@ * [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs * [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates +* [#4878](https://github.com/netbox-community/netbox/issues/4878) - Custom field data is now stored directly on each object * [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method ### REST API Changes From 0030fe1779e86eed6313f8afeb93fb21e10c21bb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Sep 2020 14:22:14 -0400 Subject: [PATCH 028/291] Fixes #5146: Add custom fields support for cables, power panels, rack reservations, and virtual chassis --- docs/release-notes/version-2.10.md | 4 ++++ netbox/dcim/api/serializers.py | 15 ++++++------ netbox/dcim/forms.py | 10 ++++---- .../dcim/migrations/0116_custom_field_data.py | 23 +++++++++++++++++++ netbox/dcim/models/devices.py | 8 +++---- netbox/dcim/models/power.py | 4 ++-- netbox/dcim/models/racks.py | 4 ++-- netbox/templates/dcim/cable.html | 1 + netbox/templates/dcim/inc/cable_form.html | 8 +++++++ netbox/templates/dcim/powerpanel.html | 1 + netbox/templates/dcim/rackreservation.html | 1 + .../templates/dcim/rackreservation_edit.html | 8 +++++++ netbox/templates/dcim/virtualchassis.html | 1 + .../templates/dcim/virtualchassis_edit.html | 13 ++++++++++- 14 files changed, 80 insertions(+), 21 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 45c192148..e9dfbc0c3 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -4,6 +4,10 @@ **NOTE:** This release completely removes support for embedded graphs. +### New Features + +* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis + ### Other Changes * [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 50c1f99ff..1b2f75201 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -168,7 +168,7 @@ class RackUnitSerializer(serializers.Serializer): occupied = serializers.BooleanField(read_only=True) -class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') rack = NestedRackSerializer() user = NestedUserSerializer() @@ -176,7 +176,7 @@ class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer class Meta: model = RackReservation - fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags'] + fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields'] class RackElevationDetailFilterSerializer(serializers.Serializer): @@ -649,7 +649,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): # Cables # -class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) @@ -667,6 +667,7 @@ class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer): fields = [ 'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', + 'custom_fields', ] def _get_termination(self, obj, side): @@ -729,21 +730,21 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'member_count'] + fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] # # Power panels # -class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') site = NestedSiteSerializer() rack_group = NestedRackGroupSerializer( @@ -755,7 +756,7 @@ class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class Meta: model = PowerPanel - fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count'] + fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count'] class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 43f77de51..1599b343d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -690,7 +690,7 @@ class RackElevationFilterForm(RackFilterForm): # Rack reservations # -class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): +class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False @@ -3608,7 +3608,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): return getattr(self.cleaned_data['termination_b_id'], 'pk', None) -class CableForm(BootstrapMixin, forms.ModelForm): +class CableForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3919,7 +3919,7 @@ class DeviceSelectionForm(forms.Form): ) -class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): +class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False @@ -3972,7 +3972,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): return instance -class VirtualChassisForm(BootstrapMixin, forms.ModelForm): +class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm): master = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, @@ -4152,7 +4152,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # Power panels # -class PowerPanelForm(BootstrapMixin, forms.ModelForm): +class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all() ) diff --git a/netbox/dcim/migrations/0116_custom_field_data.py b/netbox/dcim/migrations/0116_custom_field_data.py index cc7b8cc7f..34148bd0a 100644 --- a/netbox/dcim/migrations/0116_custom_field_data.py +++ b/netbox/dcim/migrations/0116_custom_field_data.py @@ -9,6 +9,7 @@ class Migration(migrations.Migration): ] operations = [ + # Original CustomFieldModels migrations.AddField( model_name='device', name='custom_field_data', @@ -34,4 +35,26 @@ class Migration(migrations.Migration): name='custom_field_data', field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), ), + + # Added under #5146 + migrations.AddField( + model_name='cable', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerpanel', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rackreservation', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='virtualchassis', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8b4f06108..38ed84370 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -882,8 +882,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Cables # -@extras_features('custom_links', 'export_templates', 'webhooks') -class Cable(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Cable(ChangeLoggedModel, CustomFieldModel): """ A physical connection between two endpoints. """ @@ -1168,8 +1168,8 @@ class Cable(ChangeLoggedModel): # Virtual chassis # -@extras_features('custom_links', 'export_templates', 'webhooks') -class VirtualChassis(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class VirtualChassis(ChangeLoggedModel, CustomFieldModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 08ae194ae..1b226586f 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -22,8 +22,8 @@ __all__ = ( # Power # -@extras_features('custom_links', 'export_templates', 'webhooks') -class PowerPanel(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class PowerPanel(ChangeLoggedModel, CustomFieldModel): """ A distribution point for electrical power; e.g. a data center RPP. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 4a6b56298..f09f8c828 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -561,8 +561,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return 0 -@extras_features('custom_links', 'export_templates', 'webhooks') -class RackReservation(ChangeLoggedModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RackReservation(ChangeLoggedModel, CustomFieldModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 03188163d..bdae87c48 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -83,6 +83,7 @@
    + {% include 'inc/custom_fields_panel.html' with obj=cable %} {% include 'extras/inc/tags_panel.html' with tags=cable.tags.all url='dcim:cable_list' %} {% plugin_left_page cable %}
    diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html index 98eca17d2..c0ade9aec 100644 --- a/netbox/templates/dcim/inc/cable_form.html +++ b/netbox/templates/dcim/inc/cable_form.html @@ -32,3 +32,11 @@ {% render_field form.tags %} +{% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    +{% endif %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 90956d2a3..3cad8b5b3 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -82,6 +82,7 @@ + {% include 'inc/custom_fields_panel.html' with obj=powerpanel %} {% include 'extras/inc/tags_panel.html' with tags=powerpanel.tags.all url='dcim:powerpanel_list' %} {% plugin_left_page powerpanel %} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index ab0fc0bba..5e607bcab 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -124,6 +124,7 @@ + {% include 'inc/custom_fields_panel.html' with obj=rackreservation %} {% include 'extras/inc/tags_panel.html' with tags=rackreservation.tags.all url='dcim:rackreservation_list' %} {% plugin_left_page rackreservation %} diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html index d6fa9cfcb..fd030d1fe 100644 --- a/netbox/templates/dcim/rackreservation_edit.html +++ b/netbox/templates/dcim/rackreservation_edit.html @@ -21,4 +21,12 @@ {% render_field form.tenant %} + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 18ec8c4e7..c38761d68 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -78,6 +78,7 @@ + {% include 'inc/custom_fields_panel.html' with obj=virtualchassis %} {% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %} {% plugin_left_page virtualchassis %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 54bdc9fe8..3b24cce20 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -21,9 +21,20 @@
    Virtual Chassis
    - {% render_form vc_form %} + {% render_field vc_form.name %} + {% render_field vc_form.domain %} + {% render_field vc_form.master %} + {% render_field vc_form.tags %}
    + {% if vc_form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields vc_form %} +
    +
    + {% endif %}
    Members
    From 230e7bbe34c441bd7a687e048d4bbca45f4ee670 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 10:18:03 -0400 Subject: [PATCH 029/291] Closes #1846: Enable MPTT for InventoryItem hierarchy --- docs/release-notes/version-2.10.md | 1 + netbox/dcim/api/nested_serializers.py | 3 +- netbox/dcim/api/serializers.py | 3 +- .../migrations/0117_inventoryitem_mptt.py | 44 +++++++++++++++++++ .../0118_inventoryitem_mptt_rebuild.py | 26 +++++++++++ netbox/dcim/models/device_components.py | 11 +++-- netbox/dcim/tests/test_api.py | 11 ++--- netbox/dcim/tests/test_filters.py | 6 ++- netbox/dcim/tests/test_views.py | 8 ++-- netbox/dcim/views.py | 6 +-- netbox/templates/dcim/device_inventory.html | 4 +- netbox/templates/dcim/inc/inventoryitem.html | 7 +-- 12 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 netbox/dcim/migrations/0117_inventoryitem_mptt.py create mode 100644 netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index e9dfbc0c3..48f5ae1d7 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -10,6 +10,7 @@ ### Other Changes +* [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy * [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs * [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates * [#4878](https://github.com/netbox-community/netbox/issues/4878) - Custom field data is now stored directly on each object diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 3bc953991..40b03ada6 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -305,10 +305,11 @@ class NestedDeviceBaySerializer(WritableNestedSerializer): class NestedInventoryItemSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = models.InventoryItem - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', '_depth'] # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1b2f75201..725639321 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -636,12 +636,13 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = InventoryItem fields = [ 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'discovered', 'description', 'tags', + 'discovered', 'description', 'tags', '_depth', ] diff --git a/netbox/dcim/migrations/0117_inventoryitem_mptt.py b/netbox/dcim/migrations/0117_inventoryitem_mptt.py new file mode 100644 index 000000000..2e7b34a8d --- /dev/null +++ b/netbox/dcim/migrations/0117_inventoryitem_mptt.py @@ -0,0 +1,44 @@ +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0116_custom_field_data'), + ] + + operations = [ + # The MPTT will be rebuilt in the following migration. Using dummy values for now. + migrations.AddField( + model_name='inventoryitem', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='inventoryitem', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='inventoryitem', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='inventoryitem', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + # Convert ForeignKey to TreeForeignKey + migrations.AlterField( + model_name='inventoryitem', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitem'), + ), + ] diff --git a/netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py b/netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py new file mode 100644 index 000000000..4bd0c770f --- /dev/null +++ b/netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py @@ -0,0 +1,26 @@ +from django.db import migrations +import mptt +import mptt.managers + + +def rebuild_mptt(apps, schema_editor): + manager = mptt.managers.TreeManager() + InventoryItem = apps.get_model('dcim', 'InventoryItem') + manager.model = InventoryItem + mptt.register(InventoryItem) + manager.contribute_to_class(InventoryItem, 'objects') + manager.rebuild() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0117_inventoryitem_mptt'), + ] + + operations = [ + migrations.RunPython( + code=rebuild_mptt, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4d79f3434..57c611bc9 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -6,6 +6,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Sum from django.urls import reverse +from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from dcim.choices import * @@ -15,6 +16,7 @@ from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features from utilities.fields import NaturalOrderingField +from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar @@ -952,17 +954,18 @@ class DeviceBay(ComponentModel): # @extras_features('export_templates', 'webhooks') -class InventoryItem(ComponentModel): +class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. InventoryItems are used only for inventory purposes. """ - parent = models.ForeignKey( + parent = TreeForeignKey( to='self', on_delete=models.CASCADE, related_name='child_items', blank=True, - null=True + null=True, + db_index=True ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', @@ -997,6 +1000,8 @@ class InventoryItem(ComponentModel): tags = TaggableManager(through=TaggedItem) + objects = TreeManager() + csv_headers = [ 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', ] diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 22085fdbd..286405e54 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1273,7 +1273,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase): model = InventoryItem - brief_fields = ['device', 'id', 'name', 'url'] + brief_fields = ['_depth', 'device', 'id', 'name', 'url'] @classmethod def setUpTestData(cls): @@ -1283,12 +1283,9 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site) - inventory_items = ( - InventoryItem(device=device, name='Inventory Item 1', manufacturer=manufacturer), - InventoryItem(device=device, name='Inventory Item 2', manufacturer=manufacturer), - InventoryItem(device=device, name='Inventory Item 3', manufacturer=manufacturer), - ) - InventoryItem.objects.bulk_create(inventory_items) + InventoryItem.objects.create(device=device, name='Inventory Item 1', manufacturer=manufacturer) + InventoryItem.objects.create(device=device, name='Inventory Item 2', manufacturer=manufacturer) + InventoryItem.objects.create(device=device, name='Inventory Item 3', manufacturer=manufacturer) cls.create_data = [ { diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index d4504d586..0a2794f01 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -2285,14 +2285,16 @@ class InventoryItemTestCase(TestCase): InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), ) - InventoryItem.objects.bulk_create(inventory_items) + for i in inventory_items: + i.save() child_inventory_items = ( InventoryItem(device=devices[0], name='Inventory Item 1A', parent=inventory_items[0]), InventoryItem(device=devices[1], name='Inventory Item 2A', parent=inventory_items[1]), InventoryItem(device=devices[2], name='Inventory Item 3A', parent=inventory_items[2]), ) - InventoryItem.objects.bulk_create(child_inventory_items) + for i in child_inventory_items: + i.save() def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 066ea1b02..7afde8ed2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1430,11 +1430,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): device = create_test_device('Device 1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') - InventoryItem.objects.bulk_create([ - InventoryItem(device=device, name='Inventory Item 1'), - InventoryItem(device=device, name='Inventory Item 2'), - InventoryItem(device=device, name='Inventory Item 3'), - ]) + InventoryItem.objects.create(device=device, name='Inventory Item 1') + InventoryItem.objects.create(device=device, name='Inventory Item 2') + InventoryItem.objects.create(device=device, name='Inventory Item 3') tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f9a04aede..0e322b2d3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1089,10 +1089,8 @@ class DeviceInventoryView(ObjectView): device = get_object_or_404(self.queryset, pk=pk) inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter( - device=device, parent=None - ).prefetch_related( - 'manufacturer', 'child_items' - ) + device=device + ).prefetch_related('manufacturer') return render(request, 'dcim/device_inventory.html', { 'device': device, diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 69afbb6a1..3668d3052 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -30,9 +30,7 @@ {% for item in inventory_items %} - {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} - {% include template_name %} - {% endwith %} + {% include 'dcim/inc/inventoryitem.html' %} {% endfor %}
    diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 1b103893f..9bcfffa72 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -8,7 +8,7 @@ {% endif %} - + {{ item }} @@ -38,8 +38,3 @@ {% endif %} -{% for item in item.child_items.all %} - {% with template_name='dcim/inc/inventoryitem.html' indent=indent|add:20 %} - {% include template_name %} - {% endwith %} -{% endfor %} From 52dc80209c8565e797b4c66b3162bd292c2a0195 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 11:05:31 -0400 Subject: [PATCH 030/291] Closes #1692: Allow assigment of inventory items to parent items in web UI --- docs/release-notes/version-2.10.md | 1 + netbox/dcim/forms.py | 21 +++++++++++++++++--- netbox/templates/dcim/inc/inventoryitem.html | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 48f5ae1d7..4291b641d 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -6,6 +6,7 @@ ### New Features +* [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis ### Other Changes diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1599b343d..1096ae6a7 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3284,6 +3284,13 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): queryset=Device.objects.all(), display_field='display_name' ) + parent = DynamicModelChoiceField( + queryset=InventoryItem.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -3296,7 +3303,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: model = InventoryItem fields = [ - 'name', 'label', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', + 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'tags', ] @@ -3305,6 +3313,13 @@ class InventoryItemCreateForm(ComponentCreateForm): queryset=Manufacturer.objects.all(), required=False ) + parent = DynamicModelChoiceField( + queryset=InventoryItem.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) part_id = forms.CharField( max_length=50, required=False, @@ -3319,8 +3334,8 @@ class InventoryItemCreateForm(ComponentCreateForm): required=False, ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'tags', + 'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'description', 'tags', ) diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 9bcfffa72..d56ae03c9 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -31,7 +31,7 @@ {{ item.description|placeholder }} {% if perms.dcim.change_inventoryitem %} - + {% endif %} {% if perms.dcim.delete_inventoryitem %} From 584b076886502b322c79e23cdcdab3a35754f1d5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 11:35:15 -0400 Subject: [PATCH 031/291] Closes #4956: Include inventory items on primary device view --- docs/release-notes/version-2.10.md | 1 + netbox/dcim/urls.py | 1 - netbox/dcim/views.py | 25 +- netbox/templates/dcim/device.html | 753 ++++++++++--------- netbox/templates/dcim/device_inventory.html | 63 -- netbox/templates/dcim/inc/inventoryitem.html | 4 +- 6 files changed, 415 insertions(+), 432 deletions(-) delete mode 100644 netbox/templates/dcim/device_inventory.html diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 4291b641d..54a6fcd39 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -7,6 +7,7 @@ ### New Features * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI +* [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis ### Other Changes diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 63ae5d2a4..aa0453baf 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -189,7 +189,6 @@ urlpatterns = [ path('devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0e322b2d3..31cc66bde 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1050,6 +1050,11 @@ class DeviceView(ObjectView): 'installed_device__device_type__manufacturer', ) + # Inventory items + inventoryitems = InventoryItem.objects.restrict(request.user, 'view').filter( + device=device + ).prefetch_related('manufacturer') + # Services services = Service.objects.restrict(request.user, 'view').filter(device=device) @@ -1072,9 +1077,10 @@ class DeviceView(ObjectView): 'powerports': powerports, 'poweroutlets': poweroutlets, 'interfaces': interfaces, - 'devicebays': devicebays, 'frontports': frontports, 'rearports': rearports, + 'devicebays': devicebays, + 'inventoryitems': inventoryitems, 'services': services, 'secrets': secrets, 'vc_members': vc_members, @@ -1082,23 +1088,6 @@ class DeviceView(ObjectView): }) -class DeviceInventoryView(ObjectView): - queryset = Device.objects.all() - - def get(self, request, pk): - - device = get_object_or_404(self.queryset, pk=pk) - inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter( - device=device - ).prefetch_related('manufacturer') - - return render(request, 'dcim/device_inventory.html', { - 'device': device, - 'inventory_items': inventory_items, - 'active_tab': 'inventory', - }) - - class DeviceStatusView(ObjectView): additional_permissions = ['dcim.napalm_read_device'] queryset = Device.objects.all() diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 09f6eab40..ff82a49e2 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -69,7 +69,7 @@
  • Device Bays
  • {% endif %} {% if perms.dcim.add_inventoryitem %} -
  • Inventory Items
  • +
  • Inventory Items
  • {% endif %}
    @@ -93,11 +93,6 @@ - {% if perms.dcim.napalm_read_device %} {% if device.status != 'active' %} {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} @@ -123,351 +118,13 @@ {% endblock %} {% block content %} -
    -
    -
    -
    - Device -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Site - {% if device.site.region %} - {{ device.site.region }} - - {% endif %} - {{ device.site }} -
    Rack - {% if device.rack %} - {% if device.rack.group %} - {{ device.rack.group }} - - {% endif %} - {{ device.rack }} - {% else %} - None - {% endif %} -
    Position - {% if device.parent_bay %} - {% with device.parent_bay.device as parent %} - {{ parent }} {{ device.parent_bay }} - {% if parent.position %} - (U{{ parent.position }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif device.rack and device.position %} - U{{ device.position }} / {{ device.get_face_display }} - {% elif device.rack and device.device_type.u_height %} - Not racked - {% else %} - - {% endif %} -
    Tenant - {% if device.tenant %} - {% if device.tenant.group %} - {{ device.tenant.group }} - - {% endif %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
    Device Type - {{ device.device_type.display_name }} ({{ device.device_type.u_height }}U) -
    Serial Number{{ device.serial|placeholder }}
    Asset Tag{{ device.asset_tag|placeholder }}
    -
    - {% if vc_members %} -
    -
    - Virtual Chassis -
    - - - - - - - - {% for vc_member in vc_members %} - - - - - - - {% endfor %} -
    DevicePositionMasterPriority
    - {{ vc_member }} - {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
    - -
    - {% endif %} -
    -
    - Management -
    - - - - - - - - - - - - - - - - - - - - - - {% if device.cluster %} - - - - - {% endif %} -
    Role - {{ device.device_role }} -
    Platform - {% if device.platform %} - {{ device.platform }} - {% else %} - None - {% endif %} -
    Status - {{ device.get_status_display }} -
    Primary IPv4 - {% if device.primary_ip4 %} - {{ device.primary_ip4.address.ip }} - {% if device.primary_ip4.nat_inside %} - (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) - {% elif device.primary_ip4.nat_outside %} - (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) - {% endif %} - {% else %} - - {% endif %} -
    Primary IPv6 - {% if device.primary_ip6 %} - {{ device.primary_ip6.address.ip }} - {% if device.primary_ip6.nat_inside %} - (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) - {% elif device.primary_ip6.nat_outside %} - (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) - {% endif %} - {% else %} - - {% endif %} -
    Cluster - {% if device.cluster.group %} - {{ device.cluster.group }} - - {% endif %} - {{ device.cluster }} -
    -
    - {% include 'inc/custom_fields_panel.html' with obj=device %} - {% include 'extras/inc/tags_panel.html' with tags=device.tags.all url='dcim:device_list' %} -
    -
    - Comments -
    -
    - {% if device.comments %} - {{ device.comments|render_markdown }} - {% else %} - None - {% endif %} -
    -
    - {% plugin_left_page device %} -
    -
    - {% if power_ports and poweroutlets %} -
    -
    - Power Utilization -
    - - - - - - - - - {% for pp in power_ports %} - {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %} - - - - - {% if powerfeed.available_power %} - - - {% else %} - - - {% endif %} - - {% for leg in utilization.legs %} - - - - - - {% with phase_available=powerfeed.available_power|divide:3 %} - - {% endwith %} - - {% endfor %} - {% endwith %} - {% endfor %} -
    InputOutletsAllocatedAvailableUtilization
    {{ pp }}{{ utilization.outlet_count }}{{ utilization.allocated }}VA{{ powerfeed.available_power }}VA{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
    Leg {{ leg.name }}{{ leg.outlet_count }}{{ leg.allocated }}{{ powerfeed.available_power|divide:3 }}VA{% utilization_graph leg.allocated|percentage:phase_available %}
    -
    - {% endif %} - {% if request.user.is_authenticated %} -
    -
    - Secrets -
    - {% if secrets %} - - {% for secret in secrets %} - {% include 'secrets/inc/secret_tr.html' %} - {% endfor %} -
    - {% else %} -
    - None found -
    - {% endif %} - {% if perms.secrets.add_secret %} - - {% csrf_token %} - - - {% endif %} -
    - {% endif %} -
    -
    - Services -
    - {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
    - {% else %} -
    - None -
    - {% endif %} - {% if perms.ipam.add_service %} - - {% endif %} -
    -
    -
    - Images -
    - {% include 'inc/image_attachments.html' with images=device.images.all %} - {% if perms.extras.add_imageattachment %} - - {% endif %} -
    -
    -
    - Related Devices -
    - {% if related_devices %} - - {% for rd in related_devices %} - - - - - - {% endfor %} -
    - {{ rd }} - - {% if rd.rack %} - Rack {{ rd.rack }} - {% else %} - - {% endif %} - {{ rd.device_type.display_name }}
    - {% else %} -
    None found
    - {% endif %} -
    - {% plugin_right_page device %} -
    -
    - {% plugin_full_width_page device %} -
    -
    -
    -
    -
    diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html deleted file mode 100644 index 3668d3052..000000000 --- a/netbox/templates/dcim/device_inventory.html +++ /dev/null @@ -1,63 +0,0 @@ -{% extends 'dcim/device.html' %} -{% load helpers %} - -{% block title %}{{ device }} - Inventory{% endblock %} - -{% block content %} -
    -
    -
    - {% csrf_token %} -
    -
    - Inventory Items -
    - - - - {% if perms.dcim.change_inventoryitem or perms.dcim.delete_inventoryitem %} - - {% endif %} - - - - - - - - - - - - {% for item in inventory_items %} - {% include 'dcim/inc/inventoryitem.html' %} - {% endfor %} - -
    NameManufacturerPart IDSerial NumberAsset TagDiscoveredDescription
    - -
    -
    -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index d56ae03c9..f7309fa59 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -31,10 +31,10 @@ {{ item.description|placeholder }} {% if perms.dcim.change_inventoryitem %} - + {% endif %} {% if perms.dcim.delete_inventoryitem %} - + {% endif %} From 70ec5b9f37ae649fa406612a583b5e4609f1aaf2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 11:51:38 -0400 Subject: [PATCH 032/291] Annotate REST API changes in release notes --- docs/release-notes/version-2.10.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 54a6fcd39..bcd9a55b8 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -20,4 +20,10 @@ ### REST API Changes +* dcim.Cable: Added `custom_fields` +* dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning +* dcim.PowerPanel: Added `custom_fields` +* dcim.RackReservation: Added `custom_fields` +* dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed +* extras.Graph: This API endpoint has been removed (see #4349) From 0cc2a6b2cfff58ed7da5c4ebbdda2709c29dd674 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 13:03:38 -0400 Subject: [PATCH 033/291] Closes #5003: CSV import now accepts slug values for choice fields --- docs/release-notes/version-2.10.md | 1 + netbox/dcim/tests/test_views.py | 24 +++++++++---------- netbox/ipam/tests/test_views.py | 24 +++++++++---------- .../templates/utilities/obj_bulk_import.html | 11 +++++---- netbox/utilities/forms/fields.py | 15 ++++-------- 5 files changed, 36 insertions(+), 39 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index bcd9a55b8..d2bdf983b 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -8,6 +8,7 @@ * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view +* [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis ### Other Changes diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 7afde8ed2..83d8841df 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -983,9 +983,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.csv_data = ( "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face", - "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 4,Site 1,Rack Group 1,Rack 1,10,Front", - "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 5,Site 1,Rack Group 1,Rack 1,20,Front", - "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 6,Site 1,Rack Group 1,Rack 1,30,Front", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Rack Group 1,Rack 1,10,front", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Rack Group 1,Rack 1,20,front", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Rack Group 1,Rack 1,30,front", ) cls.bulk_edit_data = { @@ -1267,9 +1267,9 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.csv_data = ( "device,name,type", - "Device 1,Interface 4,1000BASE-T (1GE)", - "Device 1,Interface 5,1000BASE-T (1GE)", - "Device 1,Interface 6,1000BASE-T (1GE)", + "Device 1,Interface 4,1000base-t", + "Device 1,Interface 5,1000base-t", + "Device 1,Interface 6,1000base-t", ) @@ -1326,9 +1326,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.csv_data = ( "device,name,type,rear_port,rear_port_position", - "Device 1,Front Port 4,8P8C,Rear Port 4,1", - "Device 1,Front Port 5,8P8C,Rear Port 5,1", - "Device 1,Front Port 6,8P8C,Rear Port 6,1", + "Device 1,Front Port 4,8p8c,Rear Port 4,1", + "Device 1,Front Port 5,8p8c,Rear Port 5,1", + "Device 1,Front Port 6,8p8c,Rear Port 6,1", ) @@ -1372,9 +1372,9 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.csv_data = ( "device,name,type,positions", - "Device 1,Rear Port 4,8P8C,1", - "Device 1,Rear Port 5,8P8C,1", - "Device 1,Rear Port 6,8P8C,1", + "Device 1,Rear Port 4,8p8c,1", + "Device 1,Rear Port 5,8p8c,1", + "Device 1,Rear Port 6,8p8c,1", ) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 35bae013a..7992e4ddf 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -194,9 +194,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.csv_data = ( "vrf,prefix,status", - "VRF 1,10.4.0.0/16,Active", - "VRF 1,10.5.0.0/16,Active", - "VRF 1,10.6.0.0/16,Active", + "VRF 1,10.4.0.0/16,active", + "VRF 1,10.5.0.0/16,active", + "VRF 1,10.6.0.0/16,active", ) cls.bulk_edit_data = { @@ -244,9 +244,9 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.csv_data = ( "vrf,address,status", - "VRF 1,192.0.2.4/24,Active", - "VRF 1,192.0.2.5/24,Active", - "VRF 1,192.0.2.6/24,Active", + "VRF 1,192.0.2.4/24,active", + "VRF 1,192.0.2.5/24,active", + "VRF 1,192.0.2.6/24,active", ) cls.bulk_edit_data = { @@ -334,9 +334,9 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.csv_data = ( "vid,name,status", - "104,VLAN104,Active", - "105,VLAN105,Active", - "106,VLAN106,Active", + "104,VLAN104,active", + "105,VLAN105,active", + "106,VLAN106,active", ) cls.bulk_edit_data = { @@ -393,9 +393,9 @@ class ServiceTestCase( cls.csv_data = ( "device,name,protocol,port,description", - "Device 1,Service 1,TCP,1,First service", - "Device 1,Service 2,TCP,2,Second service", - "Device 1,Service 3,UDP,3,Third service", + "Device 1,Service 1,tcp,1,First service", + "Device 1,Service 2,tcp,2,Second service", + "Device 1,Service 3,udp,3,Third service", ) cls.bulk_edit_data = { diff --git a/netbox/templates/utilities/obj_bulk_import.html b/netbox/templates/utilities/obj_bulk_import.html index 9d63788ba..9a36e1e4b 100644 --- a/netbox/templates/utilities/obj_bulk_import.html +++ b/netbox/templates/utilities/obj_bulk_import.html @@ -66,7 +66,7 @@ {% endif %} - {% if field.choice_values %} + {% if field.STATIC_CHOICES %} @@ -77,9 +77,12 @@ - + + + {% for value, label in field.choices %} + {% if value %}{% endif %} + {% endfor %} + diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 6146e00d3..d15445412 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -117,18 +117,11 @@ class CSVChoiceField(forms.ChoiceField): """ Invert the provided set of choices to take the human-friendly label as input, and return the database value. """ - def __init__(self, choices, *args, **kwargs): - super().__init__(choices=choices, *args, **kwargs) - self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] - self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} + STATIC_CHOICES = True - def clean(self, value): - value = super().clean(value) - if not value: - return '' - if value not in self.choice_values: - raise forms.ValidationError("Invalid choice: {}".format(value)) - return self.choice_values[value] + def __init__(self, *, choices=(), **kwargs): + super().__init__(choices=choices, **kwargs) + self.choices = unpack_grouped_choices(choices) class CSVModelChoiceField(forms.ModelChoiceField): From ec095e58b75e67811539c8013d6635285eb243b6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 14:51:09 -0400 Subject: [PATCH 034/291] #1503: Initial work on generic secret assignments (WIP) --- netbox/dcim/models/devices.py | 6 +++ netbox/secrets/filters.py | 10 ---- .../0011_secret_generic_assignments.py | 49 +++++++++++++++++++ netbox/secrets/models.py | 46 ++++++++--------- netbox/secrets/tables.py | 9 ++-- netbox/secrets/views.py | 7 +++ netbox/templates/secrets/secret.html | 7 +-- 7 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 netbox/secrets/migrations/0011_secret_generic_assignments.py diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 38ed84370..3d7963dbb 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -582,6 +582,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) + secrets = GenericRelation( + to='secrets.Secret', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='device' + ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 78f25952a..9ccc86de4 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -35,16 +35,6 @@ class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS to_field_name='slug', label='Role (slug)', ) - 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 (name)', - ) tag = TagFilter() class Meta: diff --git a/netbox/secrets/migrations/0011_secret_generic_assignments.py b/netbox/secrets/migrations/0011_secret_generic_assignments.py new file mode 100644 index 000000000..4758a9084 --- /dev/null +++ b/netbox/secrets/migrations/0011_secret_generic_assignments.py @@ -0,0 +1,49 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def device_to_generic_assignment(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + Secret = apps.get_model('secrets', 'Secret') + + device_ct = ContentType.objects.get(app_label='dcim', model='device') + Secret.objects.update(assigned_object_type=device_ct, assigned_object_id=models.F('device_id')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('secrets', '0010_custom_field_data'), + ] + + operations = [ + migrations.AlterModelOptions( + name='secret', + options={'ordering': ('role', 'name', 'pk')}, + ), + migrations.AddField( + model_name='secret', + name='assigned_object_id', + field=models.PositiveIntegerField(blank=True, null=True), + preserve_default=False, + ), + migrations.AddField( + model_name='secret', + name='assigned_object_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='secret', + unique_together={('assigned_object_type', 'assigned_object_id', 'role', 'name')}, + ), + migrations.RunPython( + code=device_to_generic_assignment, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='secret', + name='device', + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 23a883103..2a14aef59 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -6,13 +6,14 @@ from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import force_bytes from taggit.managers import TaggableManager -from dcim.models import Device from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.querysets import RestrictedQuerySet @@ -276,17 +277,26 @@ class SecretRole(ChangeLoggedModel): class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible - SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a - Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the - ciphertext; this string is stored as plain text in the database. + SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to exactly + one NetBox object, and objects may have multiple Secrets associated with them. A name can optionally be defined + along with the ciphertext; this string is stored as plain text in the database. A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis. """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='secrets' + assigned_object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + blank=True, + null=True + ) + assigned_object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' ) role = models.ForeignKey( to='secrets.SecretRole', @@ -310,34 +320,26 @@ class Secret(ChangeLoggedModel, CustomFieldModel): objects = RestrictedQuerySet.as_manager() plaintext = None - csv_headers = ['device', 'role', 'name', 'plaintext'] + csv_headers = ['assigned_object_type', 'assigned_object_id', 'role', 'name', 'plaintext'] class Meta: - ordering = ['device', 'role', 'name'] - unique_together = ['device', 'role', 'name'] + ordering = ('role', 'name', 'pk') + unique_together = ('assigned_object_type', 'assigned_object_id', 'role', 'name') def __init__(self, *args, **kwargs): self.plaintext = kwargs.pop('plaintext', None) super().__init__(*args, **kwargs) def __str__(self): - try: - device = self.device - except Device.DoesNotExist: - device = None - if self.role and device and self.name: - return '{} for {} ({})'.format(self.role, self.device, self.name) - # Return role and device if no name is set - if self.role and device: - return '{} for {}'.format(self.role, self.device) - return 'Secret' + return self.name or 'Secret' def get_absolute_url(self): return reverse('secrets:secret', args=[self.pk]) def to_csv(self): return ( - self.device, + f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}', + self.assigned_object_id, self.role, self.name, self.plaintext or '', diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 5e8c5a8b4..7158b0b13 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -28,12 +28,15 @@ class SecretRoleTable(BaseTable): class SecretTable(BaseTable): pk = ToggleColumn() - device = tables.LinkColumn() + assigned_object = tables.Column( + linkify=True, + verbose_name='Assigned object' + ) tags = TagColumn( url_name='secrets:secret_list' ) class Meta(BaseTable.Meta): model = Secret - fields = ('pk', 'device', 'role', 'name', 'last_updated', 'hash', 'tags') - default_columns = ('pk', 'device', 'role', 'name', 'last_updated') + fields = ('pk', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags') + default_columns = ('pk', 'assigned_object', 'role', 'name', 'last_updated') diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 2872616b8..e3d607755 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -82,6 +82,13 @@ class SecretEditView(ObjectEditView): model_form = forms.SecretForm template_name = 'secrets/secret_edit.html' + def alter_obj(self, secret, request, args, kwargs): + if not secret.pk: + # Set assigned_object based on URL kwargs + model = kwargs.get('model') + secret.assigned_object = get_object_or_404(model, pk=kwargs['object_id']) + return secret + def dispatch(self, request, *args, **kwargs): # Check that the user has a valid UserKey diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 841d9843a..ce7c37c40 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -11,7 +11,8 @@ @@ -50,9 +51,9 @@ - + From 43f3e682c52202713ef6e3ed851a6c2bae5df9a7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 15:39:41 -0400 Subject: [PATCH 035/291] Support assignment of secrets to virtual machines --- netbox/secrets/forms.py | 39 +++++++++++++----- netbox/secrets/views.py | 7 ---- netbox/templates/dcim/device.html | 30 +------------- .../secrets/inc/assigned_secrets.html | 41 +++++++++++++++++++ netbox/templates/secrets/inc/secret_tr.html | 16 -------- netbox/templates/secrets/secret_edit.html | 19 ++++++++- .../virtualization/virtualmachine.html | 5 +++ netbox/virtualization/models.py | 6 +++ netbox/virtualization/views.py | 10 ++++- 9 files changed, 109 insertions(+), 64 deletions(-) create mode 100644 netbox/templates/secrets/inc/assigned_secrets.html delete mode 100644 netbox/templates/secrets/inc/secret_tr.html diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 8f04edc5b..b391b59e6 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -11,6 +11,7 @@ from utilities.forms import ( BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) +from virtualization.models import VirtualMachine from .constants import * from .models import Secret, SecretRole, UserKey @@ -64,8 +65,13 @@ class SecretRoleCSVForm(CSVModelForm): class SecretForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), + required=False, display_field='display_name' ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False + ) plaintext = forms.CharField( max_length=SECRET_PLAINTEXT_MAX_LENGTH, required=False, @@ -93,10 +99,21 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Secret fields = [ - 'device', 'role', 'name', 'plaintext', 'plaintext2', 'tags', + 'device', 'virtual_machine', 'role', 'name', 'plaintext', 'plaintext2', 'tags', ] def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance: + if type(instance.assigned_object) is Device: + initial['device'] = instance.assigned_object + elif type(instance.assigned_object) is VirtualMachine: + initial['virtual_machine'] = instance.assigned_object + kwargs['initial'] = initial + super().__init__(*args, **kwargs) # A plaintext value is required when creating a new Secret @@ -105,21 +122,23 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): def clean(self): + if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']: + raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.") + + if self.cleaned_data['device'] and self.cleaned_data['virtual_machine']: + raise forms.ValidationError("Cannot select both a device and virtual machine for secret assignment.") + # Verify that the provided plaintext values match if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']: raise forms.ValidationError({ 'plaintext2': "The two given plaintext values do not match. Please check your input." }) - # Validate uniqueness - if Secret.objects.filter( - device=self.cleaned_data['device'], - role=self.cleaned_data['role'], - name=self.cleaned_data['name'] - ).exclude(pk=self.instance.pk).exists(): - raise forms.ValidationError( - "Each secret assigned to a device must have a unique combination of role and name" - ) + def save(self, *args, **kwargs): + # Set assigned object + self.instance.assigned_object = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') + + return super().save(*args, **kwargs) class SecretCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index e3d607755..2872616b8 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -82,13 +82,6 @@ class SecretEditView(ObjectEditView): model_form = forms.SecretForm template_name = 'secrets/secret_edit.html' - def alter_obj(self, secret, request, args, kwargs): - if not secret.pk: - # Set assigned_object based on URL kwargs - model = kwargs.get('model') - secret.assigned_object = get_object_or_404(model, pk=kwargs['object_id']) - return secret - def dispatch(self, request, *args, **kwargs): # Check that the user has a valid UserKey diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ff82a49e2..6f8ae69c6 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -395,34 +395,8 @@
    DeviceAssigned object - {{ secret.device }} + {{ secret.assigned_object }}
    {% endif %} - {% if request.user.is_authenticated %} -
    -
    - Secrets -
    - {% if secrets %} - - {% for secret in secrets %} - {% include 'secrets/inc/secret_tr.html' %} - {% endfor %} -
    - {% else %} -
    - None found -
    - {% endif %} - {% if perms.secrets.add_secret %} -
    - {% csrf_token %} -
    - - {% endif %} -
    + {% if perms.secrets.view_secret %} + {% include 'secrets/inc/assigned_secrets.html' %} {% endif %}
    diff --git a/netbox/templates/secrets/inc/assigned_secrets.html b/netbox/templates/secrets/inc/assigned_secrets.html new file mode 100644 index 000000000..11ad5e75d --- /dev/null +++ b/netbox/templates/secrets/inc/assigned_secrets.html @@ -0,0 +1,41 @@ +
    +
    + Secrets +
    + {% if secrets %} + + {% for secret in secrets %} + + + + + + + {% endfor %} +
    {{ secret.role }}{{ secret.name }}******** + + + +
    + {% else %} +
    + None found +
    + {% endif %} + {% if perms.secrets.add_secret %} +
    + {% csrf_token %} +
    + + {% endif %} +
    diff --git a/netbox/templates/secrets/inc/secret_tr.html b/netbox/templates/secrets/inc/secret_tr.html deleted file mode 100644 index 2af609289..000000000 --- a/netbox/templates/secrets/inc/secret_tr.html +++ /dev/null @@ -1,16 +0,0 @@ - - {{ secret.role }} - {{ secret.name }} - ******** - - - - - - diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 0cb1eefef..d3c2f88dc 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -18,9 +18,24 @@
    {% endif %}
    -
    Secret Attributes
    +
    + Secret Assignment +
    - {% render_field form.device %} + {% with vm_tab_active=form.initial.virtual_machine %} + +
    +
    + {% render_field form.device %} +
    +
    + {% render_field form.virtual_machine %} +
    +
    + {% endwith %} {% render_field form.role %} {% render_field form.name %} {% render_field form.userkeys %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ea33aa460..3f0a37b88 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -220,6 +220,9 @@
    + {% if perms.secrets.view_secret %} + {% include 'secrets/inc/assigned_secrets.html' %} + {% endif %}
    Services @@ -325,8 +328,10 @@ {% endif %}
    +{% include 'secrets/inc/private_key_modal.html' %} {% endblock %} {% block javascript %} + {% endblock %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index a492370ef..e81ee1e49 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -270,6 +270,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): comments = models.TextField( blank=True ) + secrets = GenericRelation( + to='secrets.Secret', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='virtual_machine' + ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a06a2e5ff..5e4b99553 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -9,6 +9,7 @@ from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from secrets.models import Secret from utilities.utils import get_subquery from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView, @@ -240,23 +241,30 @@ class VirtualMachineView(ObjectView): queryset = VirtualMachine.objects.prefetch_related('tenant__group') def get(self, request, pk): - virtualmachine = get_object_or_404(self.queryset, pk=pk) + + # Interfaces interfaces = VMInterface.objects.restrict(request.user, 'view').filter( virtual_machine=virtualmachine ).prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)) ) + + # Services services = Service.objects.restrict(request.user, 'view').filter( virtual_machine=virtualmachine ).prefetch_related( Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)) ) + # Secrets + secrets = Secret.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) + return render(request, 'virtualization/virtualmachine.html', { 'virtualmachine': virtualmachine, 'interfaces': interfaces, 'services': services, + 'secrets': secrets, }) From 64adbf87695cca33df2ff00816531b152a7c9106 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 15:46:01 -0400 Subject: [PATCH 036/291] Fix migrations to ensure secret assigned_object is required --- .../0011_secret_generic_assignments.py | 20 ++++++++++++++++++- netbox/secrets/models.py | 9 ++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/netbox/secrets/migrations/0011_secret_generic_assignments.py b/netbox/secrets/migrations/0011_secret_generic_assignments.py index 4758a9084..02a0e0e21 100644 --- a/netbox/secrets/migrations/0011_secret_generic_assignments.py +++ b/netbox/secrets/migrations/0011_secret_generic_assignments.py @@ -4,9 +4,10 @@ import django.db.models.deletion def device_to_generic_assignment(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') + Device = apps.get_model('dcim', 'Device') Secret = apps.get_model('secrets', 'Secret') - device_ct = ContentType.objects.get(app_label='dcim', model='device') + device_ct = ContentType.objects.get_for_model(Device) Secret.objects.update(assigned_object_type=device_ct, assigned_object_id=models.F('device_id')) @@ -22,6 +23,8 @@ class Migration(migrations.Migration): name='secret', options={'ordering': ('role', 'name', 'pk')}, ), + + # Add assigned_object type & ID fields migrations.AddField( model_name='secret', name='assigned_object_id', @@ -34,10 +37,13 @@ class Migration(migrations.Migration): field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), preserve_default=False, ), + migrations.AlterUniqueTogether( name='secret', unique_together={('assigned_object_type', 'assigned_object_id', 'role', 'name')}, ), + + # Copy device assignments and delete device ForeignKey migrations.RunPython( code=device_to_generic_assignment, reverse_code=migrations.RunPython.noop @@ -46,4 +52,16 @@ class Migration(migrations.Migration): model_name='secret', name='device', ), + + # Remove blank/null from assigned_object fields + migrations.AlterField( + model_name='secret', + name='assigned_object_id', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='secret', + name='assigned_object_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + ), ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 2a14aef59..f5508d47d 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -286,14 +286,9 @@ class Secret(ChangeLoggedModel, CustomFieldModel): """ assigned_object_type = models.ForeignKey( to=ContentType, - on_delete=models.PROTECT, - blank=True, - null=True - ) - assigned_object_id = models.PositiveIntegerField( - blank=True, - null=True + on_delete=models.PROTECT ) + assigned_object_id = models.PositiveIntegerField() assigned_object = GenericForeignKey( ct_field='assigned_object_type', fk_field='assigned_object_id' From b2a14d4654d10c55d18c080470cadee17f1f9e11 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 16:23:17 -0400 Subject: [PATCH 037/291] Extend secret filters --- netbox/secrets/filters.py | 23 +++++++++++++++++++++ netbox/secrets/tests/test_filters.py | 30 +++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 9ccc86de4..003859b2f 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.models import Device from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter +from virtualization.models import VirtualMachine from .models import Secret, SecretRole @@ -35,6 +36,28 @@ class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS to_field_name='slug', label='Role (slug)', ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + field_name='virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + field_name='virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) tag = TagFilter() class Meta: diff --git a/netbox/secrets/tests/test_filters.py b/netbox/secrets/tests/test_filters.py index b7ac73f1d..0be1ef594 100644 --- a/netbox/secrets/tests/test_filters.py +++ b/netbox/secrets/tests/test_filters.py @@ -3,6 +3,7 @@ from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.filters import * from secrets.models import Secret, SecretRole +from virtualization.models import Cluster, ClusterType, VirtualMachine class SecretRoleTestCase(TestCase): @@ -51,6 +52,15 @@ class SecretTestCase(TestCase): ) Device.objects.bulk_create(devices) + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) + virtual_machines = ( + VirtualMachine(name='Virtual Machine 1', cluster=cluster), + VirtualMachine(name='Virtual Machine 2', cluster=cluster), + VirtualMachine(name='Virtual Machine 3', cluster=cluster), + ) + VirtualMachine.objects.bulk_create(virtual_machines) + roles = ( SecretRole(name='Secret Role 1', slug='secret-role-1'), SecretRole(name='Secret Role 2', slug='secret-role-2'), @@ -59,9 +69,12 @@ class SecretTestCase(TestCase): SecretRole.objects.bulk_create(roles) secrets = ( - Secret(device=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'), - Secret(device=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'), - Secret(device=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'), + Secret(assigned_object=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'), + Secret(assigned_object=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'), + Secret(assigned_object=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'), + Secret(assigned_object=virtual_machines[0], role=roles[0], name='Secret 4', plaintext='SECRET DATA'), + Secret(assigned_object=virtual_machines[1], role=roles[1], name='Secret 5', plaintext='SECRET DATA'), + Secret(assigned_object=virtual_machines[2], role=roles[2], name='Secret 6', plaintext='SECRET DATA'), ) # Must call save() to encrypt Secrets for s in secrets: @@ -78,9 +91,9 @@ class SecretTestCase(TestCase): def test_role(self): roles = SecretRole.objects.all()[:2] params = {'role_id': [roles[0].pk, roles[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'role': [roles[0].slug, roles[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_device(self): devices = Device.objects.all()[:2] @@ -88,3 +101,10 @@ class SecretTestCase(TestCase): 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_virtual_machine(self): + virtual_machines = VirtualMachine.objects.all()[:2] + params = {'virtual_machine_id': [virtual_machines[0].pk, virtual_machines[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'virtual_machine': [virtual_machines[0].name, virtual_machines[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From 0b33c53f47b9f1583db2b29ae8cf70a89592f25a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 18 Sep 2020 16:58:51 -0400 Subject: [PATCH 038/291] Update secrets API, views --- netbox/secrets/api/serializers.py | 21 ++++++++++++++++----- netbox/secrets/api/views.py | 4 +--- netbox/secrets/constants.py | 8 ++++++++ netbox/secrets/forms.py | 10 ++++++---- netbox/secrets/tests/test_api.py | 15 +++++++++------ netbox/secrets/tests/test_views.py | 18 ++++++++++-------- netbox/secrets/views.py | 6 +++--- 7 files changed, 53 insertions(+), 29 deletions(-) diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 2862259d8..1fd3f19ef 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,10 +1,12 @@ +from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from dcim.api.nested_serializers import NestedDeviceSerializer from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer +from secrets.constants import SECRET_ASSIGNMENT_MODELS from secrets.models import Secret, SecretRole -from utilities.api import ValidatedModelSerializer +from utilities.api import ContentTypeField, ValidatedModelSerializer, get_serializer_for_model from .nested_serializers import * @@ -23,18 +25,27 @@ class SecretRoleSerializer(ValidatedModelSerializer): class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail') - device = NestedDeviceSerializer() + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS) + ) + assigned_object = serializers.SerializerMethodField(read_only=True) role = NestedSecretRoleSerializer() plaintext = serializers.CharField() class Meta: model = Secret fields = [ - 'id', 'url', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'role', 'name', 'plaintext', + 'hash', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, obj): + serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.assigned_object, context=context).data + def validate(self, data): # Encrypt plaintext data using the master key provided from the view context diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 7db6f92b6..33cddea2b 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -46,9 +46,7 @@ class SecretRoleViewSet(ModelViewSet): # class SecretViewSet(ModelViewSet): - queryset = Secret.objects.prefetch_related( - 'device__primary_ip4', 'device__primary_ip6', 'role', 'tags', - ) + queryset = Secret.objects.prefetch_related('role', 'tags') serializer_class = serializers.SecretSerializer filterset_class = filters.SecretFilterSet diff --git a/netbox/secrets/constants.py b/netbox/secrets/constants.py index a1c3cb3da..16803820e 100644 --- a/netbox/secrets/constants.py +++ b/netbox/secrets/constants.py @@ -1,5 +1,13 @@ +from django.db.models import Q + + # # Secrets # +SECRET_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='device') | + Q(app_label='virtualization', model='virtualmachine') +) + SECRET_PLAINTEXT_MAX_LENGTH = 65535 diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index b391b59e6..8c27ff868 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -1,6 +1,7 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms +from django.contrib.contenttypes.models import ContentType from dcim.models import Device from extras.forms import ( @@ -142,10 +143,11 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): class SecretCSVForm(CustomFieldModelCSVForm): - device = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Assigned device' + assigned_object_type = CSVModelChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=SECRET_ASSIGNMENT_MODELS, + to_field_name='model', + help_text='Side A type' ) role = CSVModelChoiceField( queryset=SecretRole.objects.all(), diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 89c18b7d7..91051e77a 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -80,9 +80,9 @@ class SecretTest(APIViewTestCases.APIViewTestCase): SecretRole.objects.bulk_create(secret_roles) secrets = ( - Secret(device=device, role=secret_roles[0], name='Secret 1', plaintext='ABC'), - Secret(device=device, role=secret_roles[0], name='Secret 2', plaintext='DEF'), - Secret(device=device, role=secret_roles[0], name='Secret 3', plaintext='GHI'), + Secret(assigned_object=device, role=secret_roles[0], name='Secret 1', plaintext='ABC'), + Secret(assigned_object=device, role=secret_roles[0], name='Secret 2', plaintext='DEF'), + Secret(assigned_object=device, role=secret_roles[0], name='Secret 3', plaintext='GHI'), ) for secret in secrets: secret.encrypt(self.master_key) @@ -90,19 +90,22 @@ class SecretTest(APIViewTestCases.APIViewTestCase): self.create_data = [ { - 'device': device.pk, + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': device.pk, 'role': secret_roles[1].pk, 'name': 'Secret 4', 'plaintext': 'JKL', }, { - 'device': device.pk, + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': device.pk, 'role': secret_roles[1].pk, 'name': 'Secret 5', 'plaintext': 'MNO', }, { - 'device': device.pk, + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': device.pk, 'role': secret_roles[1].pk, 'name': 'Secret 6', 'plaintext': 'PQR', diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 3b7519b7b..2dad675a4 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -69,13 +69,14 @@ class SecretTestCase( # Create one secret per device to allow bulk-editing of names (which must be unique per device/role) Secret.objects.bulk_create(( - Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'), - Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'), - Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'), + Secret(assigned_object=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'), + Secret(assigned_object=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'), + Secret(assigned_object=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'), )) cls.form_data = { - 'device': devices[1].pk, + 'assigned_object_type': 'dcim.device', + 'assigned_object_id': devices[1].pk, 'role': secretroles[1].pk, 'name': 'Secret X', } @@ -100,11 +101,12 @@ class SecretTestCase( def test_import_objects(self): self.add_permissions('secrets.add_secret') + device = Device.objects.get(name='Device 1') csv_data = ( - "device,role,name,plaintext", - "Device 1,Secret Role 1,Secret 4,abcdefghij", - "Device 1,Secret Role 1,Secret 5,abcdefghij", - "Device 1,Secret Role 1,Secret 6,abcdefghij", + "assigned_object_type,assigned_object_id,role,name,plaintext", + f"device,{device.pk},Secret Role 1,Secret 4,abcdefghij", + f"device,{device.pk},Secret Role 1,Secret 5,abcdefghij", + f"device,{device.pk},Secret Role 1,Secret 6,abcdefghij", ) # Set the session_key cookie on the request diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 2872616b8..b01a19738 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -58,7 +58,7 @@ class SecretRoleBulkDeleteView(BulkDeleteView): # class SecretListView(ObjectListView): - queryset = Secret.objects.prefetch_related('role', 'device') + queryset = Secret.objects.prefetch_related('role', 'tags') filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm table = tables.SecretTable @@ -198,13 +198,13 @@ class SecretBulkImportView(BulkImportView): class SecretBulkEditView(BulkEditView): - queryset = Secret.objects.prefetch_related('role', 'device') + queryset = Secret.objects.prefetch_related('role') filterset = filters.SecretFilterSet table = tables.SecretTable form = forms.SecretBulkEditForm class SecretBulkDeleteView(BulkDeleteView): - queryset = Secret.objects.prefetch_related('role', 'device') + queryset = Secret.objects.prefetch_related('role') filterset = filters.SecretFilterSet table = tables.SecretTable From c9863214022d50e0431867d2c323c10e5733082b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Sep 2020 10:54:04 -0400 Subject: [PATCH 039/291] Fix "add secret" link for VMs --- netbox/templates/dcim/device.html | 14 +++- .../secrets/inc/assigned_secrets.html | 65 +++++++------------ .../virtualization/virtualmachine.html | 14 +++- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 6f8ae69c6..2918d2fe3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -396,7 +396,19 @@
    {% endif %} {% if perms.secrets.view_secret %} - {% include 'secrets/inc/assigned_secrets.html' %} +
    +
    + Secrets +
    + {% include 'secrets/inc/assigned_secrets.html' %} + {% if perms.secrets.add_secret %} + + {% endif %} +
    {% endif %}
    diff --git a/netbox/templates/secrets/inc/assigned_secrets.html b/netbox/templates/secrets/inc/assigned_secrets.html index 11ad5e75d..2da526a5c 100644 --- a/netbox/templates/secrets/inc/assigned_secrets.html +++ b/netbox/templates/secrets/inc/assigned_secrets.html @@ -1,41 +1,26 @@ -
    -
    - Secrets +{% if secrets %} + + {% for secret in secrets %} + + + + + + + {% endfor %} +
    {{ secret.role }}{{ secret.name }}******** + + + +
    +{% else %} +
    + None found
    - {% if secrets %} - - {% for secret in secrets %} - - - - - - - {% endfor %} -
    {{ secret.role }}{{ secret.name }}******** - - - -
    - {% else %} -
    - None found -
    - {% endif %} - {% if perms.secrets.add_secret %} -
    - {% csrf_token %} -
    - - {% endif %} -
    +{% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 3f0a37b88..7eabcf504 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -221,7 +221,19 @@
    {% if perms.secrets.view_secret %} - {% include 'secrets/inc/assigned_secrets.html' %} +
    +
    + Secrets +
    + {% include 'secrets/inc/assigned_secrets.html' %} + {% if perms.secrets.add_secret %} + + {% endif %} +
    {% endif %}
    From 975e7e60ffbe1ea29f67ea5f4ac7c0bc1e59ec26 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Sep 2020 11:12:17 -0400 Subject: [PATCH 040/291] Changelog for #1503 --- docs/release-notes/version-2.10.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index d2bdf983b..2b7dcd4e4 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -6,6 +6,7 @@ ### New Features +* [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields @@ -28,3 +29,4 @@ * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) +* secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`. From f97d8963f2767df5ab4fc9879d33c67100ad7003 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Sep 2020 13:21:41 -0400 Subject: [PATCH 041/291] Initial work on #2179: Allow a service to have multiple ports --- netbox/ipam/api/nested_serializers.py | 2 +- netbox/ipam/api/serializers.py | 2 +- netbox/ipam/filters.py | 2 +- netbox/ipam/forms.py | 25 ++++++----- .../migrations/0039_service_ports_array.py | 43 +++++++++++++++++++ .../ipam/migrations/0040_service_drop_port.py | 15 +++++++ netbox/ipam/models.py | 23 +++++----- netbox/ipam/tables.py | 7 ++- netbox/ipam/tests/test_api.py | 14 +++--- netbox/ipam/tests/test_filters.py | 18 ++++---- netbox/ipam/tests/test_views.py | 12 +++--- netbox/templates/ipam/service.html | 4 +- netbox/templates/ipam/service_edit.html | 2 +- 13 files changed, 118 insertions(+), 51 deletions(-) create mode 100644 netbox/ipam/migrations/0039_service_ports_array.py create mode 100644 netbox/ipam/migrations/0040_service_drop_port.py diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 18f42186f..d40c9bb29 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -117,4 +117,4 @@ class NestedServiceSerializer(WritableNestedSerializer): class Meta: model = models.Service - fields = ['id', 'url', 'name', 'protocol', 'port'] + fields = ['id', 'url', 'name', 'protocol', 'ports'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 6be0b8a42..074cba9d6 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -282,6 +282,6 @@ class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Service fields = [ - 'id', 'url', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags', + 'id', 'url', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 79fc05334..7b85c7bf3 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -546,7 +546,7 @@ class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): class Meta: model = Service - fields = ['id', 'name', 'protocol', 'port'] + fields = ['id', 'name', 'protocol'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 1e8e9038a..b10af8c4c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -10,8 +10,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm, - DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, ReturnURLForm, - SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, + ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, VirtualMachine, VMInterface from .choices import * @@ -1155,9 +1155,12 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class ServiceForm(BootstrapMixin, CustomFieldModelForm): - port = forms.IntegerField( - min_value=SERVICE_PORT_MIN, - max_value=SERVICE_PORT_MAX + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), + help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen." ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -1167,7 +1170,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Service fields = [ - 'name', 'protocol', 'port', 'ipaddresses', 'description', 'tags', + 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', ] help_texts = { 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " @@ -1244,11 +1247,11 @@ class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit required=False, widget=StaticSelect2() ) - port = forms.IntegerField( - validators=[ - MinValueValidator(1), - MaxValueValidator(65535), - ], + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), required=False ) description = forms.CharField( diff --git a/netbox/ipam/migrations/0039_service_ports_array.py b/netbox/ipam/migrations/0039_service_ports_array.py new file mode 100644 index 000000000..63e592c03 --- /dev/null +++ b/netbox/ipam/migrations/0039_service_ports_array.py @@ -0,0 +1,43 @@ +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +def replicate_ports(apps, schema_editor): + Service = apps.get_model('ipam', 'Service') + # TODO: Figure out how to cast IntegerField to an array so we can use .update() + for service in Service.objects.all(): + Service.objects.filter(pk=service.pk).update(ports=[service.port]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0038_custom_field_data'), + ] + + operations = [ + migrations.AddField( + model_name='service', + name='ports', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(65535) + ] + ), + default=[], + size=None + ), + preserve_default=False, + ), + + migrations.AlterModelOptions( + name='service', + options={'ordering': ('protocol', 'ports', 'pk')}, + ), + migrations.RunPython( + code=replicate_ports + ), + ] diff --git a/netbox/ipam/migrations/0040_service_drop_port.py b/netbox/ipam/migrations/0040_service_drop_port.py new file mode 100644 index 000000000..d1db82678 --- /dev/null +++ b/netbox/ipam/migrations/0040_service_drop_port.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0039_service_ports_array'), + ] + + operations = [ + migrations.RemoveField( + model_name='service', + name='port', + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 6e7cb0bd4..1146fab55 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -2,6 +2,7 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey 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 MaxValueValidator, MinValueValidator from django.db import models @@ -1008,12 +1009,14 @@ class Service(ChangeLoggedModel, CustomFieldModel): max_length=50, choices=ServiceProtocolChoices ) - port = models.PositiveIntegerField( - validators=[ - MinValueValidator(SERVICE_PORT_MIN), - MaxValueValidator(SERVICE_PORT_MAX) - ], - verbose_name='Port number' + ports = ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ] + ), + verbose_name='Port numbers' ) ipaddresses = models.ManyToManyField( to='ipam.IPAddress', @@ -1029,13 +1032,13 @@ class Service(ChangeLoggedModel, CustomFieldModel): objects = RestrictedQuerySet.as_manager() - csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] class Meta: - ordering = ('protocol', 'port', 'pk') # (protocol, port) may be non-unique + ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique def __str__(self): - return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + return f'{self.name} ({self.get_protocol_display()}/{self.ports})' def get_absolute_url(self): return reverse('ipam:service', args=[self.pk]) @@ -1058,6 +1061,6 @@ class Service(ChangeLoggedModel, CustomFieldModel): self.virtual_machine.name if self.virtual_machine else None, self.name, self.get_protocol_display(), - self.port, + self.ports, self.description, ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index d7a64f7db..e454e7850 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -623,11 +623,14 @@ class ServiceTable(BaseTable): parent = tables.LinkColumn( order_by=('device', 'virtual_machine') ) + ports = tables.Column( + orderable=False + ) tags = TagColumn( url_name='ipam:service_list' ) class Meta(BaseTable.Meta): model = Service - fields = ('pk', 'name', 'parent', 'protocol', 'port', 'ipaddresses', 'description', 'tags') - default_columns = ('pk', 'name', 'parent', 'protocol', 'port', 'description') + fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags') + default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 3f2ac470d..4f514aab0 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -428,7 +428,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service - brief_fields = ['id', 'name', 'port', 'protocol', 'url'] + brief_fields = ['id', 'name', 'ports', 'protocol', 'url'] @classmethod def setUpTestData(cls): @@ -444,9 +444,9 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): Device.objects.bulk_create(devices) services = ( - Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1), - Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2), - Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=3), + Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), + Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2]), + Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3]), ) Service.objects.bulk_create(services) @@ -455,18 +455,18 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): 'device': devices[1].pk, 'name': 'Service 4', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 4, + 'ports': [4], }, { 'device': devices[1].pk, 'name': 'Service 5', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 5, + 'ports': [5], }, { 'device': devices[1].pk, 'name': 'Service 6', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 6, + 'ports': [6], }, ] diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 560313f0a..91d878a01 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -742,12 +742,12 @@ class ServiceTestCase(TestCase): VirtualMachine.objects.bulk_create(virtual_machines) services = ( - Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1001), - Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1002), - Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, port=1003), - Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2001), - Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2002), - Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, port=2003), + Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]), + Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]), + Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]), + Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]), + Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]), + Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]), ) Service.objects.bulk_create(services) @@ -763,9 +763,9 @@ class ServiceTestCase(TestCase): params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_port(self): - params = {'port': ['1001', '1002', '1003']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + # def test_port(self): + # params = {'port': ['1001', '1002', '1003']} + # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_device(self): devices = Device.objects.all()[:2] diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 7992e4ddf..fc595ac9c 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -373,9 +373,9 @@ class ServiceTestCase( device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) Service.objects.bulk_create([ - Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101), - Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=102), - Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103), + Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), + Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), + Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), ]) tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') @@ -385,14 +385,14 @@ class ServiceTestCase( 'virtual_machine': None, 'name': 'Service X', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, - 'port': 999, + 'ports': '104,105', 'ipaddresses': [], 'description': 'A new service', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "device,name,protocol,port,description", + "device,name,protocol,ports,description", "Device 1,Service 1,tcp,1,First service", "Device 1,Service 2,tcp,2,Second service", "Device 1,Service 3,udp,3,Third service", @@ -400,6 +400,6 @@ class ServiceTestCase( cls.bulk_edit_data = { 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, - 'port': 888, + 'ports': '106,107', 'description': 'New description', } diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index b16e99aa3..f87a7a1a0 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -62,8 +62,8 @@ {{ service.get_protocol_display }} - Port - {{ service.port }} + Ports + {{ service.ports|join:", " }} IP Addresses diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index 521aec137..48d1b11e3 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -25,7 +25,7 @@
    {{ form.protocol }} - {{ form.port }} + {{ form.ports }}
    {% render_field form.ipaddresses %} From e77f1bdd850f087da615910a31827a020f024d0d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Sep 2020 13:31:38 -0400 Subject: [PATCH 042/291] Introduce array_to_string() utility function; add port_list property to Service --- netbox/dcim/models/racks.py | 10 ++-------- netbox/ipam/models.py | 8 ++++++-- netbox/templates/ipam/inc/service.html | 11 ++++------- netbox/templates/ipam/service.html | 2 +- netbox/utilities/utils.py | 11 +++++++++++ 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index f09f8c828..102929476 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,5 +1,4 @@ from collections import OrderedDict -from itertools import count, groupby from django.conf import settings from django.contrib.auth.models import User @@ -22,7 +21,7 @@ from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.mptt import TreeManager -from utilities.utils import serialize_object +from utilities.utils import array_to_string, serialize_object from .devices import Device from .power import PowerFeed @@ -642,9 +641,4 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel): @property def unit_list(self): - """ - Express the assigned units as a string of summarized ranges. For example: - [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" - """ - group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x)) - return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + return array_to_string(self.units) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 1146fab55..f34ed3749 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,7 +14,7 @@ from dcim.models import Device, Interface from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object +from utilities.utils import array_to_string, serialize_object from virtualization.models import VirtualMachine, VMInterface from .choices import * from .constants import * @@ -1038,7 +1038,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique def __str__(self): - return f'{self.name} ({self.get_protocol_display()}/{self.ports})' + return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' def get_absolute_url(self): return reverse('ipam:service', args=[self.pk]) @@ -1064,3 +1064,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): self.ports, self.description, ) + + @property + def port_list(self): + return array_to_string(self.ports) diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html index 9611be175..9ece30da5 100644 --- a/netbox/templates/ipam/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -1,13 +1,10 @@ - - {{ service.name }} - - - {{ service.get_protocol_display }}/{{ service.port }} - + {{ service.name }} + {{ service.get_protocol_display }} + {{ service.port_list }} {% for ip in service.ipaddresses.all %} - {{ ip.address.ip }}
    + {{ ip.address.ip }}
    {% empty %} All IPs {% endfor %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index f87a7a1a0..6a1489681 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -63,7 +63,7 @@ Ports - {{ service.ports|join:", " }} + {{ service.port_list }} IP Addresses diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index fbb7830e2..52a951555 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,6 +1,7 @@ import datetime import json from collections import OrderedDict +from itertools import count, groupby from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery @@ -282,6 +283,16 @@ def curry(_curried_func, *args, **kwargs): return _curried +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) + + # # Fake request object # From b85990daa6adc5212bf2a8a180003f102a207933 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Sep 2020 13:34:39 -0400 Subject: [PATCH 043/291] Fix return URL when editing a service --- netbox/ipam/views.py | 3 --- netbox/templates/ipam/inc/service.html | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1f0e2607e..0978ddd8e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -843,9 +843,6 @@ class ServiceEditView(ObjectEditView): ) return obj - def get_return_url(self, request, service): - return service.parent.get_absolute_url() - class ServiceBulkImportView(BulkImportView): queryset = Service.objects.all() diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html index 9ece30da5..43f39096f 100644 --- a/netbox/templates/ipam/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -15,7 +15,7 @@ {% if perms.ipam.change_service %} - + {% endif %} From 3a90366538d935edf110d8b7c1c3ad0b17ccadef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Sep 2020 14:36:58 -0400 Subject: [PATCH 044/291] Fix filtering services by port number --- netbox/ipam/filters.py | 6 +++++- netbox/ipam/tables.py | 5 +++-- netbox/ipam/tests/test_filters.py | 6 +++--- netbox/utilities/filters.py | 12 ++++++++++-- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 7b85c7bf3..69453ea6c 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, + BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine, VMInterface @@ -542,6 +542,10 @@ class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): to_field_name='name', label='Virtual machine (name)', ) + port = NumericArrayFilter( + field_name='ports', + lookup_expr='contains' + ) tag = TagFilter() class Meta: diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index e454e7850..532b0bcad 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -623,8 +623,9 @@ class ServiceTable(BaseTable): parent = tables.LinkColumn( order_by=('device', 'virtual_machine') ) - ports = tables.Column( - orderable=False + ports = tables.TemplateColumn( + template_code='{{ record.port_list }}', + verbose_name='Ports' ) tags = TagColumn( url_name='ipam:service_list' diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 91d878a01..1ecf5a486 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -763,9 +763,9 @@ class ServiceTestCase(TestCase): params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - # def test_port(self): - # params = {'port': ['1001', '1002', '1003']} - # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_port(self): + params = {'port': '1001'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_device(self): devices = Device.objects.all()[:2] diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..69ddbf704 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -68,7 +68,6 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): """ Filters for a set of Models, including all descendant models within a Tree. Example: [,] """ - def get_filter_predicate(self, v): # null value filtering if v is None: @@ -84,7 +83,6 @@ class NullableCharFieldFilter(django_filters.CharFilter): """ Allow matching on null field values by passing a special string used to signify NULL. """ - def filter(self, qs, value): if value != settings.FILTERS_NULL_CHOICE_VALUE: return super().filter(qs, value) @@ -107,6 +105,16 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): super().__init__(*args, **kwargs) +class NumericArrayFilter(django_filters.NumberFilter): + """ + Filter based on the presence of an integer within an ArrayField. + """ + def filter(self, qs, value): + if value: + value = [value] + return super().filter(qs, value) + + # # FilterSets # From 3e1961b435458f4e26fadd98ac91d5b862f22674 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Sep 2020 14:49:39 -0400 Subject: [PATCH 045/291] Changelog for #2179 --- docs/release-notes/version-2.10.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index d2bdf983b..b96aa0802 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -7,6 +7,7 @@ ### New Features * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI +* [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis @@ -28,3 +29,4 @@ * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) +* ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers From c1b57af7718f08bcef4108cb365d35a4fbd1bcbc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 10:06:13 -0400 Subject: [PATCH 046/291] Monkey-patch DRF to treat bulk_destroy as a built-in operation --- netbox/netbox/api.py | 11 +++++++++++ netbox/netbox/settings.py | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 28403f181..9d65ba8b8 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -4,11 +4,22 @@ from rest_framework import authentication, exceptions from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework.schemas import coreapi from rest_framework.utils import formatting from users.models import Token +def is_custom_action(action): + return action not in { + 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', 'bulk_destroy' + } + + +# Monkey-patch DRF to treat bulk_destroy() as a non-custom action (see #3436) +coreapi.is_custom_action = is_custom_action + + # # Renderers # diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bd247070f..7e1966edb 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -472,6 +472,13 @@ REST_FRAMEWORK = { 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, + 'SCHEMA_COERCE_METHOD_NAMES': { + # Default mappings + 'retrieve': 'read', + 'destroy': 'delete', + # Custom operations + 'bulk_destroy': 'bulk_delete', + }, 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } From 54a4f847081e8949717946434872b5a7fc4e1d0f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 10:18:15 -0400 Subject: [PATCH 047/291] Add REST API documentation for bulk object deletion --- docs/rest-api/overview.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index d16cd059d..34ea7c12f 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -529,3 +529,17 @@ Note that `DELETE` requests do not return any data: If successful, the API will !!! note You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes. + +### Deleting Multiple Objects + +NetBox supports the simultaneous deletion of multiple objects of the same type by issuing a `DELETE` request to the model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request: + +```no-highlight +curl -s -X DELETE \ +-H "Authorization: Token $TOKEN" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10}, {"id": 11}, {"id": 12}]' +``` + +!!! note + The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted. From 935d239eabd67f7fb4213cfa48d26d6145cf54a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 10:36:16 -0400 Subject: [PATCH 048/291] Changelog for #3436 --- docs/release-notes/version-2.10.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 797149643..cb970d304 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -6,6 +6,20 @@ ### New Features +#### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436)) + +The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request: + +```no-highlight +curl -s -X DELETE \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10}, {"id": 11}, {"id": 12}]' +``` + +### Enhancements + * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services @@ -23,6 +37,7 @@ ### REST API Changes +* Added support for `DELETE` operations on list endpoints * dcim.Cable: Added `custom_fields` * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning * dcim.PowerPanel: Added `custom_fields` From a998c826a8c89b3639ba7c896770713eb57d42fa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 11:42:47 -0400 Subject: [PATCH 049/291] Introduce BulkUpdateModelMixin; rename BulkDeleteSerializer --- netbox/netbox/api.py | 2 +- netbox/utilities/api.py | 55 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 9d65ba8b8..05885f2e8 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -12,7 +12,7 @@ from users.models import Token def is_custom_action(action): return action not in { - 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', 'bulk_destroy' + 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', 'bulk_update', 'bulk_destroy' } diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index acafd81bd..977b37119 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -291,7 +291,7 @@ class WritableNestedSerializer(serializers.ModelSerializer): ) -class BulkDeleteSerializer(serializers.Serializer): +class BulkOperationSerializer(serializers.Serializer): id = serializers.IntegerField() @@ -299,6 +299,49 @@ class BulkDeleteSerializer(serializers.Serializer): # Mixins # +class BulkUpdateModelMixin: + """ + Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one + or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set. + For example: + + PATCH /api/dcim/sites/ + [ + { + "id": 123, + "name": "New name" + }, + { + "id": 456, + "status": "planned" + } + ] + """ + def bulk_update(self, request): + serializer = BulkOperationSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) + + # Map update data by object ID + update_data = { + obj.pop('id'): obj for obj in request.data + } + + self.perform_bulk_update(qs, update_data) + + return Response(status=status.HTTP_200_OK) + + def perform_bulk_update(self, objects, update_data): + with transaction.atomic(): + for obj in objects: + data = update_data.get(obj.id) + serializer = self.get_serializer(obj, data=data, partial=True) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + class BulkDestroyModelMixin: """ Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one @@ -311,11 +354,11 @@ class BulkDestroyModelMixin: ] """ def bulk_destroy(self, request): - serializer = BulkDeleteSerializer(data=request.data, many=True) + serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - - pk_list = [o['id'] for o in serializer.data] - qs = self.get_queryset().filter(pk__in=pk_list) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) self.perform_bulk_destroy(qs) @@ -336,6 +379,7 @@ class ModelViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, + BulkUpdateModelMixin, BulkDestroyModelMixin, GenericViewSet): """ @@ -455,6 +499,7 @@ class OrderedDefaultRouter(DefaultRouter): # Extend the list view mappings to support the DELETE operation self.routes[0].mapping.update({ + 'patch': 'bulk_update', 'delete': 'bulk_destroy', }) From 5677fab2f9b7da6981acc64beb2ad099e9e1f414 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 11:55:46 -0400 Subject: [PATCH 050/291] Support bulk operations for both PUT and PATCH --- netbox/netbox/api.py | 5 ++++- netbox/utilities/api.py | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 05885f2e8..4b60084c6 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -12,7 +12,10 @@ from users.models import Token def is_custom_action(action): return action not in { - 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', 'bulk_update', 'bulk_destroy' + # Default actions + 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', + # Bulk operations + 'bulk_update', 'bulk_partial_update', 'bulk_destroy', } diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 977b37119..e652656d7 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -317,7 +317,8 @@ class BulkUpdateModelMixin: } ] """ - def bulk_update(self, request): + def bulk_update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) qs = self.get_queryset().filter( @@ -329,18 +330,22 @@ class BulkUpdateModelMixin: obj.pop('id'): obj for obj in request.data } - self.perform_bulk_update(qs, update_data) + self.perform_bulk_update(qs, update_data, partial=partial) return Response(status=status.HTTP_200_OK) - def perform_bulk_update(self, objects, update_data): + def perform_bulk_update(self, objects, update_data, partial): with transaction.atomic(): for obj in objects: data = update_data.get(obj.id) - serializer = self.get_serializer(obj, data=data, partial=True) + serializer = self.get_serializer(obj, data=data, partial=partial) serializer.is_valid(raise_exception=True) self.perform_update(serializer) + def bulk_partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.bulk_update(request, *args, **kwargs) + class BulkDestroyModelMixin: """ @@ -353,7 +358,7 @@ class BulkDestroyModelMixin: {"id": 456} ] """ - def bulk_destroy(self, request): + def bulk_destroy(self, request, *args, **kwargs): serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) qs = self.get_queryset().filter( @@ -499,7 +504,8 @@ class OrderedDefaultRouter(DefaultRouter): # Extend the list view mappings to support the DELETE operation self.routes[0].mapping.update({ - 'patch': 'bulk_update', + 'put': 'bulk_update', + 'patch': 'bulk_partial_update', 'delete': 'bulk_destroy', }) From 38ed612cb9a9fa3712ea9cf6025a38a3ac5a8f20 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 13:50:55 -0400 Subject: [PATCH 051/291] Add test for API bulk updates --- netbox/circuits/tests/test_api.py | 9 +++ netbox/dcim/tests/test_api.py | 91 +++++++++++++++++++++++++ netbox/extras/tests/test_api.py | 9 +++ netbox/ipam/tests/test_api.py | 27 ++++++++ netbox/tenancy/tests/test_api.py | 6 ++ netbox/utilities/testing/api.py | 27 ++++++++ netbox/virtualization/tests/test_api.py | 12 ++++ 7 files changed, 181 insertions(+) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 8a6289401..48493d5ef 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -32,6 +32,9 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): 'slug': 'provider-6', }, ] + bulk_update_data = { + 'asn': 1234, + } @classmethod def setUpTestData(cls): @@ -61,6 +64,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): 'slug': 'circuit-type-6', }, ) + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -76,6 +82,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTest(APIViewTestCases.APIViewTestCase): model = Circuit brief_fields = ['cid', 'id', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 286405e54..512d7919c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -80,6 +80,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase): 'slug': 'region-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -92,6 +95,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase): class SiteTest(APIViewTestCases.APIViewTestCase): model = Site brief_fields = ['id', 'name', 'slug', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): @@ -133,6 +139,9 @@ class SiteTest(APIViewTestCases.APIViewTestCase): class RackGroupTest(APIViewTestCases.APIViewTestCase): model = RackGroup brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -194,6 +203,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): 'color': 'ffff00', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -209,6 +221,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackTest(APIViewTestCases.APIViewTestCase): model = Rack brief_fields = ['device_count', 'display_name', 'id', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): @@ -294,6 +309,9 @@ class RackTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase): model = RackReservation brief_fields = ['id', 'units', 'url', 'user'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -356,6 +374,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): 'slug': 'manufacturer-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -371,6 +392,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): class DeviceTypeTest(APIViewTestCases.APIViewTestCase): model = DeviceType brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] + bulk_update_data = { + 'part_number': 'ABC123', + } @classmethod def setUpTestData(cls): @@ -410,6 +434,9 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -444,6 +471,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsoleServerPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -478,6 +508,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -512,6 +545,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerOutletTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -546,6 +582,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): model = InterfaceTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -583,6 +622,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): model = FrontPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -651,6 +693,9 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): model = RearPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -688,6 +733,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -739,6 +787,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): 'color': 'ffff00', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -768,6 +819,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): 'slug': 'platform-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -783,6 +837,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase): model = Device brief_fields = ['display_name', 'id', 'name', 'url'] + bulk_update_data = { + 'status': 'failed', + } @classmethod def setUpTestData(cls): @@ -921,6 +978,9 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = ConsoleServerPort @classmethod @@ -957,6 +1017,9 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = ConsolePort @classmethod @@ -993,6 +1056,9 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = PowerOutlet @classmethod @@ -1029,6 +1095,9 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = PowerPort @classmethod @@ -1065,6 +1134,9 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = Interface @classmethod @@ -1120,6 +1192,9 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = Interface @classmethod @@ -1175,6 +1250,9 @@ class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = Interface @classmethod @@ -1214,6 +1292,9 @@ class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase) class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay brief_fields = ['device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -1274,6 +1355,9 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase): model = InventoryItem brief_fields = ['_depth', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -1309,6 +1393,10 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): class CableTest(APIViewTestCases.APIViewTestCase): model = Cable brief_fields = ['id', 'label', 'url'] + bulk_update_data = { + 'length': 100, + 'length_unit': 'm', + } # TODO: Allow updating cable terminations test_update_object = None @@ -1894,6 +1982,9 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): class PowerFeedTest(APIViewTestCases.APIViewTestCase): model = PowerFeed brief_fields = ['cable', 'id', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index f66fea2ac..860aed56f 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -49,6 +49,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -91,6 +94,9 @@ class TagTest(APIViewTestCases.APIViewTestCase): 'slug': 'tag-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -164,6 +170,9 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase): 'data': {'more_baz': None}, }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 4f514aab0..2cc24b6ae 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -37,6 +37,9 @@ class VRFTest(APIViewTestCases.APIViewTestCase): 'rd': '65000:6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -66,6 +69,9 @@ class RIRTest(APIViewTestCases.APIViewTestCase): 'slug': 'rir-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -81,6 +87,9 @@ class RIRTest(APIViewTestCases.APIViewTestCase): class AggregateTest(APIViewTestCases.APIViewTestCase): model = Aggregate brief_fields = ['family', 'id', 'prefix', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -131,6 +140,9 @@ class RoleTest(APIViewTestCases.APIViewTestCase): 'slug': 'role-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -157,6 +169,9 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): 'prefix': '192.168.6.0/24', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -328,6 +343,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): 'address': '192.168.0.6/24', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -357,6 +375,9 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): 'slug': 'vlan-group-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -372,6 +393,9 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): class VLANTest(APIViewTestCases.APIViewTestCase): model = VLAN brief_fields = ['display_name', 'id', 'name', 'url', 'vid'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -429,6 +453,9 @@ class VLANTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service brief_fields = ['id', 'name', 'ports', 'protocol', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index f04b2a7ce..7af3c8d79 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -17,6 +17,9 @@ class AppTest(APITestCase): class TenantGroupTest(APIViewTestCases.APIViewTestCase): model = TenantGroup brief_fields = ['_depth', 'id', 'name', 'slug', 'tenant_count', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -52,6 +55,9 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): class TenantTest(APIViewTestCases.APIViewTestCase): model = Tenant brief_fields = ['id', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 2c6c70fea..222e3bdce 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -234,6 +234,7 @@ class APIViewTestCases: class UpdateObjectViewTestCase(APITestCase): update_data = {} + bulk_update_data = None def test_update_object_without_permission(self): """ @@ -268,6 +269,32 @@ class APIViewTestCases: instance.refresh_from_db() self.assertInstanceEqual(instance, self.update_data, api=True) + def test_bulk_update_objects(self): + """ + PATCH a set of objects in a single request. + """ + if self.bulk_update_data is None: + self.skipTest("Bulk update data not set") + + # Add object-level permission + obj_perm = ObjectPermission( + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + id_list = self._get_queryset().values_list('id', flat=True)[:3] + self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update") + data = [ + {'id': id, **self.bulk_update_data} for id in id_list + ] + + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + for instance in self._get_queryset().filter(pk__in=id_list): + self.assertInstanceEqual(instance, self.bulk_update_data, api=True) + class DeleteObjectViewTestCase(APITestCase): def test_delete_object_without_permission(self): diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 28d4bbb99..d0e3fccfa 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -34,6 +34,9 @@ class ClusterTypeTest(APIViewTestCases.APIViewTestCase): 'slug': 'cluster-type-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -63,6 +66,9 @@ class ClusterGroupTest(APIViewTestCases.APIViewTestCase): 'slug': 'cluster-type-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -123,6 +129,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): class VirtualMachineTest(APIViewTestCases.APIViewTestCase): model = VirtualMachine brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'status': 'staged', + } @classmethod def setUpTestData(cls): @@ -196,6 +205,9 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): class VMInterfaceTest(APIViewTestCases.APIViewTestCase): model = VMInterface brief_fields = ['id', 'name', 'url', 'virtual_machine'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): From c3eb2eb6011a9b227f3fb78939eab71dfd69bafe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 14:02:31 -0400 Subject: [PATCH 052/291] Add documentation for API bulk updates --- docs/rest-api/overview.md | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index 34ea7c12f..a3c8143eb 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -468,16 +468,16 @@ http://netbox/api/dcim/sites/ \ ] ``` -### Modifying an Object +### Updating an Object To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified. ```no-highlight curl -s -X PATCH \ -> -H "Authorization: Token $TOKEN" \ -> -H "Content-Type: application/json" \ -> http://netbox/api/ipam/prefixes/18691/ \ -> --data '{"status": "reserved"}' | jq '.' +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/ipam/prefixes/18691/ \ +--data '{"status": "reserved"}' | jq '.' ``` ```json @@ -515,6 +515,23 @@ curl -s -X PATCH \ !!! note "PUT versus PATCH" The NetBox REST API support the use of either `PUT` or `PATCH` to modify an existing object. The difference is that a `PUT` request requires the user to specify a _complete_ representation of the object being modified, whereas a `PATCH` request need include only the attributes that are being updated. For most purposes, using `PATCH` is recommended. +### Updating Multiple Objects + +Multiple objects can be updated simultaneously by issuing a `PUT` or `PATCH` request to a model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted and the attributes to be updated. For example, to update sites with IDs 10 and 11 to a status of "active", issue the following request: + +```no-highlight +curl -s -X PATCH \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]' +``` + +Note that there is no requirement for the attributes to be identical among objects. For instance, it's possible to update the status of one site along with the name of another in the same request. + +!!! note + The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated. + ### Deleting an Object To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body. @@ -537,6 +554,7 @@ NetBox supports the simultaneous deletion of multiple objects of the same type b ```no-highlight curl -s -X DELETE \ -H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ http://netbox/api/dcim/sites/ \ --data '[{"id": 10}, {"id": 11}, {"id": 12}]' ``` From 5ba4252388a3dc7e60472e05e82f839e2d82ee9e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 14:49:49 -0400 Subject: [PATCH 053/291] Changelog for #4882 --- docs/release-notes/version-2.10.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index cb970d304..67665ab38 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -18,6 +18,18 @@ http://netbox/api/dcim/sites/ \ --data '[{"id": 10}, {"id": 11}, {"id": 12}]' ``` +#### REST API Bulk Update ([#4882](https://github.com/netbox-community/netbox/issues/4882)) + +Similar to bulk deletion, the REST API also now supports bulk updates. Send a `PUT` or `PATCH` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object and the attribute(s) to be updated. For example, to set a description for sites with IDs 10 and 11, issue the following request: + +```no-highlight +curl -s -X PATCH \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10, "description": "Foo"}, {"id": 11, "description": "Bar"}]' +``` + ### Enhancements * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines @@ -37,7 +49,7 @@ http://netbox/api/dcim/sites/ \ ### REST API Changes -* Added support for `DELETE` operations on list endpoints +* Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints * dcim.Cable: Added `custom_fields` * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning * dcim.PowerPanel: Added `custom_fields` From 0c3fafdfef9057d33f6a92689359a10e946d5a5d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 16:06:38 -0400 Subject: [PATCH 054/291] Closes #4897: Allow filtering by content type identified as . string --- docs/release-notes/version-2.10.md | 3 + netbox/extras/filters.py | 8 ++- netbox/extras/forms.py | 4 +- netbox/extras/tests/test_filters.py | 103 +++++++++++++++++++++++++++- netbox/utilities/filters.py | 21 ++++++ 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 67665ab38..bd54006fb 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -35,6 +35,7 @@ http://netbox/api/dcim/sites/ \ * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services +* [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `.` string * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis @@ -57,5 +58,7 @@ http://netbox/api/dcim/sites/ \ * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) +* extras.ImageAttachment: Filtering by `content_type` now takes a string in the form `.` +* extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `.` * ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers * secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`. diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 8741c7f13..da2097f61 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,7 +4,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup -from utilities.filters import BaseFilterSet +from utilities.filters import BaseFilterSet, ContentTypeFilter from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag @@ -81,10 +81,11 @@ class ExportTemplateFilterSet(BaseFilterSet): class ImageAttachmentFilterSet(BaseFilterSet): + content_type = ContentTypeFilter() class Meta: model = ImageAttachment - fields = ['id', 'content_type', 'object_id', 'name'] + fields = ['id', 'content_type_id', 'object_id', 'name'] class TagFilterSet(BaseFilterSet): @@ -234,11 +235,12 @@ class ObjectChangeFilterSet(BaseFilterSet): label='Search', ) time = django_filters.DateTimeFromToRangeFilter() + changed_object_type = ContentTypeFilter() class Meta: model = ObjectChange fields = [ - 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', + 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id', 'object_repr', ] diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 2cdd5d2ed..d7cbede69 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -361,8 +361,8 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): api_url='/api/users/users/', ) ) - changed_object_type = forms.ModelChoiceField( - queryset=ContentType.objects.order_by('model'), + changed_object_type_id = forms.ModelChoiceField( + queryset=ContentType.objects.order_by('app_label', 'model'), required=False, widget=ContentTypeSelect(), label='Object Type' diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index e96293b20..d3be69557 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -1,9 +1,14 @@ +import uuid + +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.models import DeviceRole, Platform, Rack, Region, Site +from extras.choices import ObjectChangeActionChoices from extras.filters import * -from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag +from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, Tag +from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -298,4 +303,98 @@ class TagTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -# TODO: ObjectChangeFilter test +class ObjectChangeTestCase(TestCase): + queryset = ObjectChange.objects.all() + filterset = ObjectChangeFilterSet + + @classmethod + def setUpTestData(cls): + users = ( + User(username='user1'), + User(username='user2'), + User(username='user3'), + ) + User.objects.bulk_create(users) + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + ipaddress = IPAddress.objects.create(address='192.0.2.1/24') + + object_changes = ( + ObjectChange( + user=users[0], + user_name=users[0].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_CREATE, + changed_object=site, + object_repr=str(site), + object_data={'name': site.name, 'slug': site.slug} + ), + ObjectChange( + user=users[0], + user_name=users[0].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_UPDATE, + changed_object=site, + object_repr=str(site), + object_data={'name': site.name, 'slug': site.slug} + ), + ObjectChange( + user=users[1], + user_name=users[1].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_DELETE, + changed_object=site, + object_repr=str(site), + object_data={'name': site.name, 'slug': site.slug} + ), + ObjectChange( + user=users[1], + user_name=users[1].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_CREATE, + changed_object=ipaddress, + object_repr=str(ipaddress), + object_data={'address': ipaddress.address, 'status': ipaddress.status} + ), + ObjectChange( + user=users[2], + user_name=users[2].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_UPDATE, + changed_object=ipaddress, + object_repr=str(ipaddress), + object_data={'address': ipaddress.address, 'status': ipaddress.status} + ), + ObjectChange( + user=users[2], + user_name=users[2].username, + request_id=uuid.uuid4(), + action=ObjectChangeActionChoices.ACTION_DELETE, + changed_object=ipaddress, + object_repr=str(ipaddress), + object_data={'address': ipaddress.address, 'status': ipaddress.status} + ), + ) + ObjectChange.objects.bulk_create(object_changes) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:3]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + # def test_user(self): + # params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)} + # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + # params = {'user': ['user1', 'user2']} + # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_user_name(self): + params = {'user_name': ['user1', 'user2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_changed_object_type(self): + params = {'changed_object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_changed_object_type_id(self): + params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 69ddbf704..20cb77bdc 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,4 +1,5 @@ import django_filters +from django_filters.constants import EMPTY_VALUES from copy import deepcopy from dcim.forms import MACAddressField from django import forms @@ -115,6 +116,26 @@ class NumericArrayFilter(django_filters.NumberFilter): return super().filter(qs, value) +class ContentTypeFilter(django_filters.CharFilter): + """ + Allow specifying a ContentType by . (e.g. "dcim.site"). + """ + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + + try: + app_label, model = value.lower().split('.') + except ValueError: + return qs.none() + return qs.filter( + **{ + f'{self.field_name}__app_label': app_label, + f'{self.field_name}__model': model + } + ) + + # # FilterSets # From 116b20cb9f3984dda98ca8c5895445402ed35c05 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 22 Sep 2020 16:26:08 -0400 Subject: [PATCH 055/291] Fix ImageAttachmentTestCase --- netbox/extras/tests/test_filters.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index d3be69557..4310ee8f0 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -109,12 +109,12 @@ class ImageAttachmentTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_content_type(self): - params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk} + params = {'content_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_content_type_and_object_id(self): + def test_content_type_id_and_object_id(self): params = { - 'content_type': ContentType.objects.get(app_label='dcim', model='site').pk, + 'content_type_id': ContentType.objects.get(app_label='dcim', model='site').pk, 'object_id': [Site.objects.first().pk], } self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -381,6 +381,7 @@ class ObjectChangeTestCase(TestCase): params = {'id': self.queryset.values_list('pk', flat=True)[:3]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + # TODO: Merge #5167 from develop # def test_user(self): # params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)} # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) From dfb5a06d9dea49dbb3b215126e654aeda67a7821 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 11:25:52 -0400 Subject: [PATCH 056/291] Introduce the RouteTarget model --- netbox/ipam/api/nested_serializers.py | 13 +++ netbox/ipam/api/serializers.py | 22 +++-- netbox/ipam/api/urls.py | 3 + netbox/ipam/api/views.py | 12 ++- netbox/ipam/constants.py | 1 + netbox/ipam/filters.py | 23 ++++- netbox/ipam/forms.py | 63 +++++++++++++- netbox/ipam/migrations/0041_routetarget.py | 34 ++++++++ netbox/ipam/models.py | 44 ++++++++++ netbox/ipam/tables.py | 22 ++++- netbox/ipam/urls.py | 13 ++- netbox/ipam/views.py | 52 +++++++++++- netbox/templates/inc/nav_menu.html | 9 ++ netbox/templates/ipam/routetarget.html | 98 ++++++++++++++++++++++ 14 files changed, 397 insertions(+), 12 deletions(-) create mode 100644 netbox/ipam/migrations/0041_routetarget.py create mode 100644 netbox/templates/ipam/routetarget.html diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index d40c9bb29..004ac070c 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -9,6 +9,7 @@ __all__ = [ 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', + 'NestedRouteTargetSerializer', 'NestedServiceSerializer', 'NestedVLANGroupSerializer', 'NestedVLANSerializer', @@ -29,6 +30,18 @@ class NestedVRFSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'rd', 'display_name', 'prefix_count'] +# +# Route targets +# + +class NestedRouteTargetSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') + + class Meta: + model = models.RouteTarget + fields = ['id', 'url', 'name'] + + # # RIRs/aggregates # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 074cba9d6..ffaefad6d 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,20 +3,17 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, - get_serializer_for_model, + ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, get_serializer_for_model, ) from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * @@ -40,6 +37,21 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): ] +# +# Route targets +# + +class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = RouteTarget + fields = [ + 'id', 'url', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + ] + + # # RIRs/aggregates # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e297d6451..a8cbf7a29 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -8,6 +8,9 @@ router.APIRootView = views.IPAMRootView # VRFs router.register('vrfs', views.VRFViewSet) +# Route targets +router.register('route-targets', views.RouteTargetViewSet) + # RIRs router.register('rirs', views.RIRViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dd0731bb8..69277a8ea 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -10,7 +10,7 @@ from rest_framework.routers import APIRootView from extras.api.views import CustomFieldModelViewSet from ipam import filters -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from utilities.api import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery @@ -38,6 +38,16 @@ class VRFViewSet(CustomFieldModelViewSet): filterset_class = filters.VRFFilterSet +# +# Route targets +# + +class RouteTargetViewSet(CustomFieldModelViewSet): + queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') + serializer_class = serializers.RouteTargetSerializer + filterset_class = filters.RouteTargetFilterSet + + # # RIRs # diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 1ad355aec..e8825ad18 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -16,6 +16,7 @@ BGP_ASN_MAX = 2**32 - 1 # * Type 1 (32-bit IPv4 address : 16-bit integer) # * Type 2 (32-bit AS number : 16-bit integer) # 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535) +# Also used for RouteTargets VRF_RD_MAX_LENGTH = 21 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 69453ea6c..6059b4330 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -13,7 +13,7 @@ from utilities.filters import ( ) from virtualization.models import VirtualMachine, VMInterface from .choices import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF __all__ = ( @@ -22,6 +22,7 @@ __all__ = ( 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', + 'RouteTargetFilterSet', 'ServiceFilterSet', 'VLANFilterSet', 'VLANGroupFilterSet', @@ -50,6 +51,26 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create fields = ['id', 'name', 'rd', 'enforce_unique'] +class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + class Meta: + model = RouteTarget + fields = ['id', 'name'] + + class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index fd1dd00c6..ed6071756 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.core.validators import MaxValueValidator, MinValueValidator from dcim.models import Device, Interface, Rack, Region, Site from extras.forms import ( @@ -16,7 +15,7 @@ from utilities.forms import ( from virtualization.models import Cluster, VirtualMachine, VMInterface from .choices import * from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) @@ -98,6 +97,66 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): tag = TagFilterField(model) +# +# Route targets +# + +class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = RouteTarget + fields = [ + 'name', 'description', 'tenant_group', 'tenant', 'tags', + ] + + +class RouteTargetCSVForm(CustomFieldModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = RouteTarget + fields = RouteTarget.csv_headers + + +class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + widget=forms.MultipleHiddenInput() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = [ + 'tenant', 'description', + ] + + +class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): + model = RouteTarget + field_order = ['q', 'name', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' + ) + tag = TagFilterField(model) + + # # RIRs # diff --git a/netbox/ipam/migrations/0041_routetarget.py b/netbox/ipam/migrations/0041_routetarget.py new file mode 100644 index 000000000..d2e800be2 --- /dev/null +++ b/netbox/ipam/migrations/0041_routetarget.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1 on 2020-09-24 15:19 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0010_custom_field_data'), + ('extras', '0052_delete_customfieldchoice_customfieldvalue'), + ('ipam', '0040_service_drop_port'), + ] + + operations = [ + migrations.CreateModel( + name='RouteTarget', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('name', models.CharField(max_length=21, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='route_targets', to='tenancy.tenant')), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index c11f0e296..f743fe5b0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -107,6 +107,50 @@ class VRF(ChangeLoggedModel, CustomFieldModel): return self.name +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RouteTarget(ChangeLoggedModel, CustomFieldModel): + """ + A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. + """ + name = models.CharField( + max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) + unique=True, + help_text='Route target value (formatted in accordance with RFC 4360)' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='route_targets', + blank=True, + null=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'description', 'tenant'] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:routetarget', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.description, + self.tenant.name if self.tenant else None, + ) + + class RIR(ChangeLoggedModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 3e89ece64..6a76b5c91 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -5,7 +5,7 @@ from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn from virtualization.models import VMInterface -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """
    @@ -176,6 +176,26 @@ class VRFTable(BaseTable): default_columns = ('pk', 'name', 'rd', 'tenant', 'description') +# +# Route targets +# + +class RouteTargetTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + tags = TagColumn( + url_name='ipam:vrf_list' + ) + + class Meta(BaseTable.Meta): + model = RouteTarget + fields = ('pk', 'name', 'tenant', 'description', 'tags') + default_columns = ('pk', 'name', 'tenant', 'description') + + # # RIRs # diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 533335816..9b0dc581b 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,7 @@ from django.urls import path from extras.views import ObjectChangeLogView from . import views -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF app_name = 'ipam' urlpatterns = [ @@ -18,6 +18,17 @@ urlpatterns = [ path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + # Route targets + path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), + path('route-targets/add/', views.RouteTargetEditView.as_view(), name='routetarget_add'), + path('route-targets/import/', views.RouteTargetBulkImportView.as_view(), name='routetarget_import'), + path('route-targets/edit/', views.RouteTargetBulkEditView.as_view(), name='routetarget_bulk_edit'), + path('route-targets/delete/', views.RouteTargetBulkDeleteView.as_view(), name='routetarget_bulk_delete'), + path('route-targets//', views.RouteTargetView.as_view(), name='routetarget'), + path('route-targets//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), + path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), + path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), + # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 64e71b69b..240bfedd3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -16,7 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables from .choices import * from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans @@ -74,6 +74,56 @@ class VRFBulkDeleteView(BulkDeleteView): table = tables.VRFTable +# +# Route targets +# + +class RouteTargetListView(ObjectListView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + filterset_form = forms.RouteTargetFilterForm + table = tables.RouteTargetTable + + +class RouteTargetView(ObjectView): + queryset = RouteTarget.objects.all() + + def get(self, request, pk): + routetarget = get_object_or_404(self.queryset, pk=pk) + + return render(request, 'ipam/routetarget.html', { + 'routetarget': routetarget, + }) + + +class RouteTargetEditView(ObjectEditView): + queryset = RouteTarget.objects.all() + model_form = forms.RouteTargetForm + + +class RouteTargetDeleteView(ObjectDeleteView): + queryset = RouteTarget.objects.all() + + +class RouteTargetBulkImportView(BulkImportView): + queryset = RouteTarget.objects.all() + model_form = forms.RouteTargetCSVForm + table = tables.RouteTargetTable + + +class RouteTargetBulkEditView(BulkEditView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + table = tables.RouteTargetTable + form = forms.RouteTargetBulkEditForm + + +class RouteTargetBulkDeleteView(BulkDeleteView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + table = tables.RouteTargetTable + + # # RIRs # diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index bf3d349cc..12c579bdd 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -331,6 +331,15 @@ {% endif %} VRFs + + {% if perms.ipam.add_routetarget %} +
    + + +
    + {% endif %} + Route Targets +
  • diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html new file mode 100644 index 000000000..d891f9241 --- /dev/null +++ b/netbox/templates/ipam/routetarget.html @@ -0,0 +1,98 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load plugins %} + +{% block header %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% plugin_buttons routetarget %} + {% if perms.ipam.add_routetarget %} + {% clone_button routetarget %} + {% endif %} + {% if perms.ipam.change_routetarget %} + {% edit_button routetarget %} + {% endif %} + {% if perms.ipam.delete_routetarget %} + {% delete_button routetarget %} + {% endif %} +
    +

    {% block title %}Route target {{ routetarget }}{% endblock %}

    + {% include 'inc/created_updated.html' with obj=routetarget %} +
    + {% custom_links routetarget %} +
    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Route Target +
    + + + + + + + + + + + + + +
    Name{{ routetarget.name }}
    Tenant + {% if routetarget.tenant %} + {{ routetarget.tenant }} + {% else %} + None + {% endif %} +
    Description{{ vrf.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=routetarget.tags.all url='ipam:routetarget_list' %} + {% plugin_left_page routetarget %} +
    +
    + {% include 'inc/custom_fields_panel.html' with obj=routetarget %} + {% plugin_right_page routetarget %} +
    +
    +
    +
    + {% plugin_full_width_page routetarget %} +
    +
    +{% endblock %} From f684d07c616f567855dbab32d557d76b4c61e986 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 12:09:28 -0400 Subject: [PATCH 057/291] Model import/export route targets on VRFs --- netbox/ipam/api/serializers.py | 6 ++- netbox/ipam/api/views.py | 4 +- netbox/ipam/filters.py | 44 ++++++++++++++++++++++ netbox/ipam/forms.py | 35 +++++++++++++++-- netbox/ipam/migrations/0041_routetarget.py | 10 +++++ netbox/ipam/models.py | 10 +++++ netbox/ipam/views.py | 22 +++++++++++ netbox/templates/ipam/routetarget.html | 4 +- netbox/templates/ipam/vrf.html | 4 +- netbox/templates/ipam/vrf_edit.html | 7 ++++ 10 files changed, 138 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ffaefad6d..0022dbd73 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -26,14 +26,16 @@ from .nested_serializers import * class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) + import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) + export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) ipaddress_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VRF fields = [ - 'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', - 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', + 'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', + 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69277a8ea..449ef3245 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -30,7 +30,9 @@ class IPAMRootView(APIRootView): # class VRFViewSet(CustomFieldModelViewSet): - queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate( + queryset = VRF.objects.prefetch_related('tenant').prefetch_related( + 'import_targets', 'export_targets', 'tags' + ).annotate( ipaddress_count=get_subquery(IPAddress, 'vrf'), prefix_count=get_subquery(Prefix, 'vrf') ).order_by(*VRF._meta.ordering) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 6059b4330..0cbbd3f78 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -35,6 +35,28 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create method='search', label='Search', ) + import_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets', + queryset=RouteTarget.objects.all(), + label='Import target', + ) + import_target = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Import target (name)', + ) + export_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets', + queryset=RouteTarget.objects.all(), + label='Export target', + ) + export_target = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Export target (name)', + ) tag = TagFilter() def search(self, queryset, name, value): @@ -56,6 +78,28 @@ class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet method='search', label='Search', ) + importing_vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='importing_vrfs', + queryset=VRF.objects.all(), + label='Importing VRF', + ) + importing_vrf = django_filters.ModelMultipleChoiceFilter( + field_name='importing_vrfs__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='Import VRF (RD)', + ) + exporting_vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_vrfs', + queryset=VRF.objects.all(), + label='Exporting VRF', + ) + exporting_vrf = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_vrfs__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='Export VRF (RD)', + ) tag = TagFilter() def search(self, queryset, name, value): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index ed6071756..714279859 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -31,6 +31,14 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + import_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + export_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -39,7 +47,8 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = VRF fields = [ - 'name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags', + 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', + 'tags', ] labels = { 'rd': "RD", @@ -89,11 +98,21 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VRF - field_order = ['q', 'tenant_group', 'tenant'] + field_order = ['q', 'import_target', 'export_target', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) + import_target = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + to_field_name='name', + required=False + ) + export_target = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + to_field_name='name', + required=False + ) tag = TagFilterField(model) @@ -149,11 +168,21 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulk class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = RouteTarget - field_order = ['q', 'name', 'tenant_group', 'tenant'] + field_order = ['q', 'name', 'tenant_group', 'tenant', 'importing_vrfs', 'exporting_vrfs'] q = forms.CharField( required=False, label='Search' ) + importing_vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='Imported by VRF' + ) + exporting_vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='Exported by VRF' + ) tag = TagFilterField(model) diff --git a/netbox/ipam/migrations/0041_routetarget.py b/netbox/ipam/migrations/0041_routetarget.py index d2e800be2..9cc37b742 100644 --- a/netbox/ipam/migrations/0041_routetarget.py +++ b/netbox/ipam/migrations/0041_routetarget.py @@ -31,4 +31,14 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), + migrations.AddField( + model_name='vrf', + name='export_targets', + field=models.ManyToManyField(blank=True, related_name='exporting_vrfs', to='ipam.RouteTarget'), + ), + migrations.AddField( + model_name='vrf', + name='import_targets', + field=models.ManyToManyField(blank=True, related_name='importing_vrfs', to='ipam.RouteTarget'), + ), ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f743fe5b0..f7e4d9cf4 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -71,6 +71,16 @@ class VRF(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_vrfs', + blank=True + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_vrfs', + blank=True + ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 240bfedd3..bc3e4b69b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -39,9 +39,20 @@ class VRFView(ObjectView): vrf = get_object_or_404(self.queryset, pk=pk) prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count() + import_targets_table = tables.RouteTargetTable( + vrf.import_targets.prefetch_related('tenant'), + orderable=False + ) + export_targets_table = tables.RouteTargetTable( + vrf.export_targets.prefetch_related('tenant'), + orderable=False + ) + return render(request, 'ipam/vrf.html', { 'vrf': vrf, 'prefix_count': prefix_count, + 'import_targets_table': import_targets_table, + 'export_targets_table': export_targets_table, }) @@ -91,8 +102,19 @@ class RouteTargetView(ObjectView): def get(self, request, pk): routetarget = get_object_or_404(self.queryset, pk=pk) + importing_vrfs_table = tables.VRFTable( + routetarget.importing_vrfs.prefetch_related('tenant'), + orderable=False + ) + exporting_vrfs_table = tables.VRFTable( + routetarget.exporting_vrfs.prefetch_related('tenant'), + orderable=False + ) + return render(request, 'ipam/routetarget.html', { 'routetarget': routetarget, + 'importing_vrfs_table': importing_vrfs_table, + 'exporting_vrfs_table': exporting_vrfs_table, }) diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index d891f9241..2271a8b39 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -83,10 +83,12 @@
    {% include 'extras/inc/tags_panel.html' with tags=routetarget.tags.all url='ipam:routetarget_list' %} + {% include 'inc/custom_fields_panel.html' with obj=routetarget %} {% plugin_left_page routetarget %}
    - {% include 'inc/custom_fields_panel.html' with obj=routetarget %} + {% include 'panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %} + {% include 'panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} {% plugin_right_page routetarget %}
    diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 6fb6d725f..ef77b5dea 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -99,10 +99,12 @@
    {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %} + {% include 'inc/custom_fields_panel.html' with obj=vrf %} {% plugin_left_page vrf %}
    - {% include 'inc/custom_fields_panel.html' with obj=vrf %} + {% include 'panel_table.html' with table=import_targets_table heading="Import Route Targets" %} + {% include 'panel_table.html' with table=export_targets_table heading="Export Route Targets" %} {% plugin_right_page vrf %}
    diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index a2ff51d9b..41b80bca9 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -11,6 +11,13 @@ {% render_field form.description %} +
    +
    Route Targets
    +
    + {% render_field form.import_targets %} + {% render_field form.export_targets %} +
    +
    Tenancy
    From 47fd9cab1cc84dbaf76e7efd92061aa9581a84ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 13:51:17 -0400 Subject: [PATCH 058/291] Add tests for route targets; extend VRF tests --- netbox/ipam/tests/test_api.py | 31 +++++++- netbox/ipam/tests/test_filters.py | 115 +++++++++++++++++++++++++++++- netbox/ipam/tests/test_views.py | 42 ++++++++++- 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 2cc24b6ae..db98713d0 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from utilities.testing import APITestCase, APIViewTestCases, disable_warnings @@ -52,6 +52,35 @@ class VRFTest(APIViewTestCases.APIViewTestCase): VRF.objects.bulk_create(vrfs) +class RouteTargetTest(APIViewTestCases.APIViewTestCase): + model = RouteTarget + brief_fields = ['id', 'name', 'url'] + create_data = [ + { + 'name': '65000:1004', + }, + { + 'name': '65000:1005', + }, + { + 'name': '65000:1006', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + route_targets = ( + RouteTarget(name='65000:1001'), + RouteTarget(name='65000:1002'), + RouteTarget(name='65000:1003'), + ) + RouteTarget.objects.bulk_create(route_targets) + + class RIRTest(APIViewTestCases.APIViewTestCase): model = RIR brief_fields = ['aggregate_count', 'id', 'name', 'slug', 'url'] diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 1ecf5a486..aa607eb6b 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -3,7 +3,7 @@ from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site from ipam.choices import * from ipam.filters import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup @@ -15,6 +15,13 @@ class VRFTestCase(TestCase): @classmethod def setUpTestData(cls): + route_targets = ( + RouteTarget(name='65000:1001'), + RouteTarget(name='65000:1002'), + RouteTarget(name='65000:1003'), + ) + RouteTarget.objects.bulk_create(route_targets) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -39,6 +46,12 @@ class VRFTestCase(TestCase): VRF(name='VRF 6', rd='65000:600', tenant=tenants[2], enforce_unique=True), ) VRF.objects.bulk_create(vrfs) + vrfs[0].import_targets.add(route_targets[0]) + vrfs[0].export_targets.add(route_targets[0]) + vrfs[1].import_targets.add(route_targets[1]) + vrfs[1].export_targets.add(route_targets[1]) + vrfs[2].import_targets.add(route_targets[2]) + vrfs[2].export_targets.add(route_targets[2]) def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} @@ -58,6 +71,20 @@ class VRFTestCase(TestCase): params = {'enforce_unique': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_import_target(self): + route_targets = RouteTarget.objects.all()[:2] + params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'import_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_export_target(self): + route_targets = RouteTarget.objects.all()[:2] + params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'export_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} @@ -73,6 +100,92 @@ class VRFTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class RouteTargetTestCase(TestCase): + queryset = RouteTarget.objects.all() + filterset = RouteTargetFilterSet + + @classmethod + def setUpTestData(cls): + + tenant_groups = ( + TenantGroup(name='Tenant group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant group 3', slug='tenant-group-3'), + ) + for tenantgroup in tenant_groups: + tenantgroup.save() + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), + ) + Tenant.objects.bulk_create(tenants) + + route_targets = ( + RouteTarget(name='65000:1001', tenant=tenants[0]), + RouteTarget(name='65000:1002', tenant=tenants[0]), + RouteTarget(name='65000:1003', tenant=tenants[0]), + RouteTarget(name='65000:1004', tenant=tenants[0]), + RouteTarget(name='65000:2001', tenant=tenants[1]), + RouteTarget(name='65000:2002', tenant=tenants[1]), + RouteTarget(name='65000:2003', tenant=tenants[1]), + RouteTarget(name='65000:2004', tenant=tenants[1]), + RouteTarget(name='65000:3001', tenant=tenants[2]), + RouteTarget(name='65000:3002', tenant=tenants[2]), + RouteTarget(name='65000:3003', tenant=tenants[2]), + RouteTarget(name='65000:3004', tenant=tenants[2]), + ) + RouteTarget.objects.bulk_create(route_targets) + + vrfs = ( + VRF(name='VRF 1', rd='65000:100'), + VRF(name='VRF 2', rd='65000:200'), + VRF(name='VRF 3', rd='65000:300'), + ) + VRF.objects.bulk_create(vrfs) + vrfs[0].import_targets.add(route_targets[0], route_targets[1]) + vrfs[0].export_targets.add(route_targets[2], route_targets[3]) + vrfs[1].import_targets.add(route_targets[4], route_targets[5]) + vrfs[1].export_targets.add(route_targets[6], route_targets[7]) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['65000:1001', '65000:1002', '65000:1003']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_importing_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'importing_vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'importing_vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_exporting_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'exporting_vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'exporting_vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + + def test_tenant_group(self): + tenant_groups = TenantGroup.objects.all()[:2] + params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + + class RIRTestCase(TestCase): queryset = RIR.objects.all() filterset = RIRFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index fc595ac9c..db96bb896 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,7 +4,7 @@ from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from tenancy.models import Tenant from utilities.testing import ViewTestCases @@ -52,6 +52,46 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = RouteTarget + + @classmethod + def setUpTestData(cls): + + tenants = ( + Tenant(name='Tenant A', slug='tenant-a'), + Tenant(name='Tenant B', slug='tenant-b'), + ) + Tenant.objects.bulk_create(tenants) + + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + + route_targets = ( + RouteTarget(name='65000:1001', tenant=tenants[0]), + RouteTarget(name='65000:1002', tenant=tenants[1]), + RouteTarget(name='65000:1003'), + ) + RouteTarget.objects.bulk_create(route_targets) + + cls.form_data = { + 'name': '65000:100', + 'description': 'A new route target', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,tenant,description", + "65000:1004,Tenant A,Foo", + "65000:1005,Tenant B,Bar", + "65000:1006,,No tenant", + ) + + cls.bulk_edit_data = { + 'tenant': tenants[1].pk, + 'description': 'New description', + } + + class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RIR From cca217388679680b37a830d7f0649c22078acc6f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 14:18:08 -0400 Subject: [PATCH 059/291] Documentation for #259 --- docs/core-functionality/ipam.md | 1 + docs/models/ipam/routetarget.md | 5 +++++ docs/models/ipam/vrf.md | 2 ++ docs/release-notes/version-2.10.md | 4 ++++ 4 files changed, 12 insertions(+) create mode 100644 docs/models/ipam/routetarget.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index e5ab22f19..dd6eee77b 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -15,3 +15,4 @@ --- {!docs/models/ipam/vrf.md!} +{!docs/models/ipam/routetarget.md!} diff --git a/docs/models/ipam/routetarget.md b/docs/models/ipam/routetarget.md new file mode 100644 index 000000000..b71e96904 --- /dev/null +++ b/docs/models/ipam/routetarget.md @@ -0,0 +1,5 @@ +# Route Targets + +A route target is a particular type of [extended BGP community](https://tools.ietf.org/html/rfc4360#section-4) used to control the redistribution of routes among VRF tables in a network. Route targets can be assigned to individual VRFs in NetBox as import or export targets (or both) to model this exchange in an L3VPN. Each route target must be given a unique name, which should be in a format prescribed by [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), similar to a VR route distinguisher. + +Each route target can optionally be assigned to a tenant, and may have tags assigned to it. diff --git a/docs/models/ipam/vrf.md b/docs/models/ipam/vrf.md index 599d05c82..392141fdd 100644 --- a/docs/models/ipam/vrf.md +++ b/docs/models/ipam/vrf.md @@ -10,3 +10,5 @@ By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This b !!! note Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. + +Each VRF may have one or more import and/or export route targets applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs. diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index bd54006fb..31437bacb 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -6,6 +6,10 @@ ### New Features +#### Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259)) + +This release introduces support for model L3VPN route targets, which can be used to control the redistribution of routing information among VRFs. Each VRF may be assigned one or more route targets in the import or export direction (or both). Like VRFs, route targets may be assigned to tenants and may have tags applied to them. + #### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436)) The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request: From bddd0103103f5b8e0e5c27a409cae9c448b82bdd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 14:45:14 -0400 Subject: [PATCH 060/291] Annotate REST API changes fro #259 --- docs/release-notes/version-2.10.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 31437bacb..ef693a9de 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -64,5 +64,7 @@ http://netbox/api/dcim/sites/ \ * extras.Graph: This API endpoint has been removed (see #4349) * extras.ImageAttachment: Filtering by `content_type` now takes a string in the form `.` * extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `.` +* ipam.RouteTarget: New endpoint * ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers +* ipam.VRF: Added `import_targets` and `export_targets` fields * secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`. From 1b55285167c7ce33f5624298c9fd8810414c0d64 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 16:35:53 -0400 Subject: [PATCH 061/291] Relocate CSS classes for ChoiceFields from model to ChoiceSet --- netbox/circuits/choices.py | 9 +++++ netbox/circuits/models.py | 11 +----- netbox/dcim/choices.py | 44 ++++++++++++++++++++++++ netbox/dcim/models/devices.py | 20 ++--------- netbox/dcim/models/power.py | 16 ++------- netbox/dcim/models/racks.py | 10 +----- netbox/dcim/models/sites.py | 10 +----- netbox/extras/choices.py | 14 ++++---- netbox/extras/templatetags/log_levels.py | 2 +- netbox/ipam/choices.py | 32 +++++++++++++++++ netbox/ipam/models.py | 40 +++------------------ netbox/virtualization/choices.py | 9 +++++ netbox/virtualization/models.py | 11 +----- 13 files changed, 114 insertions(+), 114 deletions(-) diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 1b5e69cb5..bbf536800 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -23,6 +23,15 @@ class CircuitStatusChoices(ChoiceSet): (STATUS_DECOMMISSIONED, 'Decommissioned'), ) + CSS_CLASSES = { + STATUS_DEPROVISIONING: 'warning', + STATUS_ACTIVE: 'success', + STATUS_PLANNED: 'info', + STATUS_PROVISIONING: 'primary', + STATUS_OFFLINE: 'danger', + STATUS_DECOMMISSIONED: 'default', + } + # # CircuitTerminations diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index fbe568f18..408a53c3c 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -191,15 +191,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', ] - STATUS_CLASS_MAP = { - CircuitStatusChoices.STATUS_DEPROVISIONING: 'warning', - CircuitStatusChoices.STATUS_ACTIVE: 'success', - CircuitStatusChoices.STATUS_PLANNED: 'info', - CircuitStatusChoices.STATUS_PROVISIONING: 'primary', - CircuitStatusChoices.STATUS_OFFLINE: 'danger', - CircuitStatusChoices.STATUS_DECOMMISSIONED: 'default', - } - class Meta: ordering = ['provider', 'cid'] unique_together = ['provider', 'cid'] @@ -224,7 +215,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): ) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return CircuitStatusChoices.CSS_CLASSES.get(self.status) def _get_termination(self, side): for ct in self.terminations.all(): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fa4f81792..d6aee8637 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -21,6 +21,14 @@ class SiteStatusChoices(ChoiceSet): (STATUS_RETIRED, 'Retired'), ) + CSS_CLASSES = { + STATUS_PLANNED: 'info', + STATUS_STAGING: 'primary', + STATUS_ACTIVE: 'success', + STATUS_DECOMMISSIONING: 'warning', + STATUS_RETIRED: 'danger', + } + # # Racks @@ -74,6 +82,14 @@ class RackStatusChoices(ChoiceSet): (STATUS_DEPRECATED, 'Deprecated'), ) + CSS_CLASSES = { + STATUS_RESERVED: 'warning', + STATUS_AVAILABLE: 'success', + STATUS_PLANNED: 'info', + STATUS_ACTIVE: 'primary', + STATUS_DEPRECATED: 'danger', + } + class RackDimensionUnitChoices(ChoiceSet): @@ -147,6 +163,16 @@ class DeviceStatusChoices(ChoiceSet): (STATUS_DECOMMISSIONING, 'Decommissioning'), ) + CSS_CLASSES = { + STATUS_OFFLINE: 'warning', + STATUS_ACTIVE: 'success', + STATUS_PLANNED: 'info', + STATUS_STAGED: 'primary', + STATUS_FAILED: 'danger', + STATUS_INVENTORY: 'default', + STATUS_DECOMMISSIONING: 'warning', + } + # # ConsolePorts @@ -933,6 +959,12 @@ class CableStatusChoices(ChoiceSet): (STATUS_DECOMMISSIONING, 'Decommissioning'), ) + CSS_CLASSES = { + STATUS_CONNECTED: 'success', + STATUS_PLANNED: 'info', + STATUS_DECOMMISSIONING: 'warning', + } + class CableLengthUnitChoices(ChoiceSet): @@ -967,6 +999,13 @@ class PowerFeedStatusChoices(ChoiceSet): (STATUS_FAILED, 'Failed'), ) + CSS_CLASSES = { + STATUS_OFFLINE: 'warning', + STATUS_ACTIVE: 'success', + STATUS_PLANNED: 'info', + STATUS_FAILED: 'danger', + } + class PowerFeedTypeChoices(ChoiceSet): @@ -978,6 +1017,11 @@ class PowerFeedTypeChoices(ChoiceSet): (TYPE_REDUNDANT, 'Redundant'), ) + CSS_CLASSES = { + TYPE_PRIMARY: 'success', + TYPE_REDUNDANT: 'info', + } + class PowerFeedSupplyChoices(ChoiceSet): diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 3d7963dbb..463b1a3e3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -600,16 +600,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', ] - STATUS_CLASS_MAP = { - DeviceStatusChoices.STATUS_OFFLINE: 'warning', - DeviceStatusChoices.STATUS_ACTIVE: 'success', - DeviceStatusChoices.STATUS_PLANNED: 'info', - DeviceStatusChoices.STATUS_STAGED: 'primary', - DeviceStatusChoices.STATUS_FAILED: 'danger', - DeviceStatusChoices.STATUS_INVENTORY: 'default', - DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning', - } - class Meta: ordering = ('_name', 'pk') # Name may be null unique_together = ( @@ -881,7 +871,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): return Device.objects.filter(parent_bay__device=self.pk) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return DeviceStatusChoices.CSS_CLASSES.get(self.status) # @@ -973,12 +963,6 @@ class Cable(ChangeLoggedModel, CustomFieldModel): 'color', 'length', 'length_unit', ] - STATUS_CLASS_MAP = { - CableStatusChoices.STATUS_CONNECTED: 'success', - CableStatusChoices.STATUS_PLANNED: 'info', - CableStatusChoices.STATUS_DECOMMISSIONING: 'warning', - } - class Meta: ordering = ['pk'] unique_together = ( @@ -1159,7 +1143,7 @@ class Cable(ChangeLoggedModel, CustomFieldModel): ) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return CableStatusChoices.CSS_CLASSES.get(self.status) def get_compatible_types(self): """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 1b226586f..f55d077a4 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -156,18 +156,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): 'available_power', ] - STATUS_CLASS_MAP = { - PowerFeedStatusChoices.STATUS_OFFLINE: 'warning', - PowerFeedStatusChoices.STATUS_ACTIVE: 'success', - PowerFeedStatusChoices.STATUS_PLANNED: 'info', - PowerFeedStatusChoices.STATUS_FAILED: 'danger', - } - - TYPE_CLASS_MAP = { - PowerFeedTypeChoices.TYPE_PRIMARY: 'success', - PowerFeedTypeChoices.TYPE_REDUNDANT: 'info', - } - class Meta: ordering = ['power_panel', 'name'] unique_together = ['power_panel', 'name'] @@ -225,7 +213,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): return self.power_panel def get_type_class(self): - return self.TYPE_CLASS_MAP.get(self.type) + return PowerFeedTypeChoices.CSS_CLASSES.get(self.type) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return PowerFeedStatusChoices.CSS_CLASSES.get(self.status) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 102929476..409db14c5 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -276,14 +276,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel): 'outer_depth', 'outer_unit', ] - STATUS_CLASS_MAP = { - RackStatusChoices.STATUS_RESERVED: 'warning', - RackStatusChoices.STATUS_AVAILABLE: 'success', - RackStatusChoices.STATUS_PLANNED: 'info', - RackStatusChoices.STATUS_ACTIVE: 'primary', - RackStatusChoices.STATUS_DEPRECATED: 'danger', - } - class Meta: ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique unique_together = ( @@ -379,7 +371,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return self.name def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return RackStatusChoices.CSS_CLASSES.get(self.status) def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 0d33da806..0adc9aac5 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -199,14 +199,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] - STATUS_CLASS_MAP = { - SiteStatusChoices.STATUS_PLANNED: 'info', - SiteStatusChoices.STATUS_STAGING: 'primary', - SiteStatusChoices.STATUS_ACTIVE: 'success', - SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning', - SiteStatusChoices.STATUS_RETIRED: 'danger', - } - class Meta: ordering = ('_name',) @@ -238,4 +230,4 @@ class Site(ChangeLoggedModel, CustomFieldModel): ) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return SiteStatusChoices.CSS_CLASSES.get(self.status) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 7b4b1665a..7e1e7a036 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -99,13 +99,13 @@ class LogLevelChoices(ChoiceSet): (LOG_FAILURE, 'Failure'), ) - CLASS_MAP = ( - (LOG_DEFAULT, 'default'), - (LOG_SUCCESS, 'success'), - (LOG_INFO, 'info'), - (LOG_WARNING, 'warning'), - (LOG_FAILURE, 'danger'), - ) + CSS_CLASSES = { + LOG_DEFAULT: 'default', + LOG_SUCCESS: 'success', + LOG_INFO: 'info', + LOG_WARNING: 'warning', + LOG_FAILURE: 'danger', + } # diff --git a/netbox/extras/templatetags/log_levels.py b/netbox/extras/templatetags/log_levels.py index c92ff8cdf..050a6996d 100644 --- a/netbox/extras/templatetags/log_levels.py +++ b/netbox/extras/templatetags/log_levels.py @@ -13,5 +13,5 @@ def log_level(level): """ return { 'name': LogLevelChoices.as_dict()[level], - 'class': dict(LogLevelChoices.CLASS_MAP)[level] + 'class': LogLevelChoices.CSS_CLASSES.get(level) } diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index f3ff19ddc..5b7dd16bd 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -30,6 +30,13 @@ class PrefixStatusChoices(ChoiceSet): (STATUS_DEPRECATED, 'Deprecated'), ) + CSS_CLASSES = { + STATUS_CONTAINER: 'default', + STATUS_ACTIVE: 'primary', + STATUS_RESERVED: 'info', + STATUS_DEPRECATED: 'danger', + } + # # IPAddresses @@ -51,6 +58,14 @@ class IPAddressStatusChoices(ChoiceSet): (STATUS_SLAAC, 'SLAAC'), ) + CSS_CLASSES = { + STATUS_ACTIVE: 'primary', + STATUS_RESERVED: 'info', + STATUS_DEPRECATED: 'danger', + STATUS_DHCP: 'success', + STATUS_SLAAC: 'success', + } + class IPAddressRoleChoices(ChoiceSet): @@ -74,6 +89,17 @@ class IPAddressRoleChoices(ChoiceSet): (ROLE_CARP, 'CARP'), ) + CSS_CLASSES = { + ROLE_LOOPBACK: 'default', + ROLE_SECONDARY: 'primary', + ROLE_ANYCAST: 'warning', + ROLE_VIP: 'success', + ROLE_VRRP: 'success', + ROLE_HSRP: 'success', + ROLE_GLBP: 'success', + ROLE_CARP: 'success', + } + # # VLANs @@ -91,6 +117,12 @@ class VLANStatusChoices(ChoiceSet): (STATUS_DEPRECATED, 'Deprecated'), ) + CSS_CLASSES = { + STATUS_ACTIVE: 'primary', + STATUS_RESERVED: 'info', + STATUS_DEPRECATED: 'danger', + } + # # Services diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f7e4d9cf4..89ea672c7 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -420,13 +420,6 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', ] - STATUS_CLASS_MAP = { - 'container': 'default', - 'active': 'primary', - 'reserved': 'info', - 'deprecated': 'danger', - } - class Meta: ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique verbose_name_plural = 'prefixes' @@ -507,7 +500,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): prefix_length = property(fset=_set_prefix_length) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return PrefixStatusChoices.CSS_CLASSES.get(self.status) def get_duplicates(self): return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk) @@ -699,25 +692,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): 'vrf', 'tenant', 'status', 'role', 'description', ] - STATUS_CLASS_MAP = { - 'active': 'primary', - 'reserved': 'info', - 'deprecated': 'danger', - 'dhcp': 'success', - 'slaac': 'success', - } - - ROLE_CLASS_MAP = { - 'loopback': 'default', - 'secondary': 'primary', - 'anycast': 'warning', - 'vip': 'success', - 'vrrp': 'success', - 'hsrp': 'success', - 'glbp': 'success', - 'carp': 'success', - } - class Meta: ordering = ('address', 'pk') # address may be non-unique verbose_name = 'IP address' @@ -840,10 +814,10 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): mask_length = property(fset=_set_mask_length) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return IPAddressStatusChoices.CSS_CLASSES.get(self.status) def get_role_class(self): - return self.ROLE_CLASS_MAP[self.role] + return IPAddressRoleChoices.CSS_CLASSES.get(self.role) class VLANGroup(ChangeLoggedModel): @@ -967,12 +941,6 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): 'site', 'group', 'tenant', 'status', 'role', 'description', ] - STATUS_CLASS_MAP = { - 'active': 'primary', - 'reserved': 'info', - 'deprecated': 'danger', - } - class Meta: ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique unique_together = [ @@ -1013,7 +981,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): return f'{self.name} ({self.vid})' def get_status_class(self): - return self.STATUS_CLASS_MAP[self.status] + return VLANStatusChoices.CSS_CLASSES.get(self.status) def get_interfaces(self): # Return all device interfaces assigned to this VLAN diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 083470bd9..9c4eb6cd5 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -22,3 +22,12 @@ class VirtualMachineStatusChoices(ChoiceSet): (STATUS_FAILED, 'Failed'), (STATUS_DECOMMISSIONING, 'Decommissioning'), ) + + CSS_CLASSES = { + STATUS_OFFLINE: 'warning', + STATUS_ACTIVE: 'success', + STATUS_PLANNED: 'info', + STATUS_STAGED: 'primary', + STATUS_FAILED: 'danger', + STATUS_DECOMMISSIONING: 'warning', + } diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index e81ee1e49..2c9cd2a9b 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -287,15 +287,6 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', ] - STATUS_CLASS_MAP = { - VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning', - VirtualMachineStatusChoices.STATUS_ACTIVE: 'success', - VirtualMachineStatusChoices.STATUS_PLANNED: 'info', - VirtualMachineStatusChoices.STATUS_STAGED: 'primary', - VirtualMachineStatusChoices.STATUS_FAILED: 'danger', - VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning', - } - class Meta: ordering = ('name', 'pk') # Name may be non-unique unique_together = [ @@ -355,7 +346,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) def get_status_class(self): - return self.STATUS_CLASS_MAP.get(self.status) + return VirtualMachineStatusChoices.CSS_CLASSES.get(self.status) @property def primary_ip(self): From 18a8a91d57271b632583ba53572dc9d010113b2d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 25 Sep 2020 10:52:14 -0400 Subject: [PATCH 062/291] Introduce ChoiceFieldColumn to replace template columns --- netbox/circuits/tables.py | 10 ++------- netbox/dcim/tables.py | 39 ++++++++------------------------- netbox/ipam/tables.py | 30 ++++++++++++------------- netbox/ipam/utils.py | 2 +- netbox/utilities/tables.py | 16 ++++++++++++++ netbox/virtualization/tables.py | 11 ++-------- 6 files changed, 44 insertions(+), 64 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 4aca8688f..782b02394 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,13 +2,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn from .models import Circuit, CircuitType, Provider -STATUS_LABEL = """ -{{ record.get_status_display }} -""" - # # Providers @@ -64,9 +60,7 @@ class CircuitTable(BaseTable): viewname='circuits:provider', args=[Accessor('provider__slug')] ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() tenant = tables.TemplateColumn( template_code=COL_TENANT ) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 371eff9db..4486ecd1b 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -3,7 +3,8 @@ from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn, + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, TagColumn, + ToggleColumn, ) from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -99,14 +100,6 @@ PLATFORM_VM_COUNT = """ {{ value|default:0 }} """ -STATUS_LABEL = """ -{{ record.get_status_display }} -""" - -TYPE_LABEL = """ -{{ record.get_type_display }} -""" - DEVICE_PRIMARY_IP = """ {{ record.primary_ip6.address.ip|default:"" }} {% if record.primary_ip6 and record.primary_ip4 %}
    {% endif %} @@ -187,9 +180,7 @@ class SiteTable(BaseTable): name = tables.LinkColumn( order_by=('_name',) ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() region = tables.TemplateColumn( template_code=SITE_REGION_LINK ) @@ -272,9 +263,7 @@ class RackTable(BaseTable): tenant = tables.TemplateColumn( template_code=COL_TENANT ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() role = ColoredLabelColumn() u_height = tables.TemplateColumn( template_code="{{ record.u_height }}U", @@ -595,9 +584,7 @@ class DeviceTable(BaseTable): order_by=('_name',), template_code=DEVICE_LINK ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() tenant = tables.TemplateColumn( template_code=COL_TENANT ) @@ -663,9 +650,7 @@ class DeviceImportTable(BaseTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() tenant = tables.TemplateColumn( template_code=COL_TENANT ) @@ -876,9 +861,7 @@ class CableTable(BaseTable): orderable=False, verbose_name='Termination B' ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() length = tables.TemplateColumn( template_code=CABLE_LENGTH, order_by='_abs_length' @@ -1062,12 +1045,8 @@ class PowerFeedTable(BaseTable): rack = tables.Column( linkify=True ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) - type = tables.TemplateColumn( - template_code=TYPE_LABEL - ) + status = ChoiceFieldColumn() + type = ChoiceFieldColumn() max_utilization = tables.TemplateColumn( template_code="{{ value }}%" ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 6a76b5c91..e8b2474ea 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -1,12 +1,15 @@ import django_tables2 as tables +from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn from virtualization.models import VMInterface from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +AVAILABLE_LABEL = mark_safe('Available') + RIR_UTILIZATION = """
    {% if record.stats.total %} @@ -327,8 +330,8 @@ class PrefixTable(BaseTable): template_code=PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}} ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL + status = ChoiceFieldColumn( + default=AVAILABLE_LABEL ) vrf = tables.TemplateColumn( template_code=VRF_LINK, @@ -400,9 +403,10 @@ class IPAddressTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL + status = ChoiceFieldColumn( + default=AVAILABLE_LABEL ) + role = ChoiceFieldColumn() tenant = tables.TemplateColumn( template_code=TENANT_LINK ) @@ -461,9 +465,7 @@ class IPAddressAssignTable(BaseTable): template_code=IPADDRESS_ASSIGN_LINK, verbose_name='IP Address' ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() assigned_object = tables.Column( orderable=False ) @@ -485,9 +487,7 @@ class InterfaceIPAddressTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() tenant = tables.TemplateColumn( template_code=TENANT_LINK ) @@ -543,8 +543,8 @@ class VLANTable(BaseTable): tenant = tables.TemplateColumn( template_code=COL_TENANT ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL + status = ChoiceFieldColumn( + default=AVAILABLE_LABEL ) role = tables.TemplateColumn( template_code=VLAN_ROLE_LINK @@ -630,9 +630,7 @@ class InterfaceVLANTable(BaseTable): tenant = tables.TemplateColumn( template_code=COL_TENANT ) - status = tables.TemplateColumn( - template_code=STATUS_LABEL - ) + status = ChoiceFieldColumn() role = tables.TemplateColumn( template_code=VLAN_ROLE_LINK ) diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index f3cc0cb52..0414a01e0 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -11,7 +11,7 @@ def add_available_prefixes(parent, prefix_list): # Find all unallocated space available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list]) - available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()] + available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()] # Concatenate and sort complete list of children prefix_list = list(prefix_list) + available_prefixes diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index d1f17ff1e..76c37b403 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -174,6 +174,22 @@ class ButtonsColumn(tables.TemplateColumn): return '' +class ChoiceFieldColumn(tables.Column): + """ + Render a ChoiceField value inside a indicating a particular CSS class. This is useful for displaying colored + choices. The CSS class is derived by calling .get_FOO_class() on the row record. + """ + def render(self, record, bound_column, value): + if value: + name = bound_column.name + css_class = getattr(record, f'get_{name}_class')() + label = getattr(record, f'get_{name}_display')() + return mark_safe( + f'{label}' + ) + return self.default + + class ColorColumn(tables.Column): """ Display a color (#RRGGBB). diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 5f5b9326d..7cb684dee 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,15 +1,10 @@ import django_tables2 as tables -from django_tables2.utils import Accessor from dcim.tables import BaseInterfaceTable from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ButtonsColumn, ColoredLabelColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, TagColumn, ToggleColumn from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface -VIRTUALMACHINE_STATUS = """ -{{ record.get_status_display }} -""" - VIRTUALMACHINE_PRIMARY_IP = """ {{ record.primary_ip6.address.ip|default:"" }} {% if record.primary_ip6 and record.primary_ip4 %}
    {% endif %} @@ -99,9 +94,7 @@ class ClusterTable(BaseTable): class VirtualMachineTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - status = tables.TemplateColumn( - template_code=VIRTUALMACHINE_STATUS - ) + status = ChoiceFieldColumn() cluster = tables.Column( linkify=True ) From 28f0da0bc1570bd17e1846c921a72eb7f2697a9c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 25 Sep 2020 12:42:17 -0400 Subject: [PATCH 063/291] Introduce LinkedCountColumn to standardize approach to counting related items in tables --- netbox/dcim/tables.py | 75 ++++++++++++--------------------- netbox/ipam/tables.py | 30 ++++++------- netbox/secrets/tables.py | 6 ++- netbox/tenancy/tables.py | 6 ++- netbox/utilities/tables.py | 24 +++++++++++ netbox/virtualization/tables.py | 22 ++++------ 6 files changed, 82 insertions(+), 81 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 4486ecd1b..1053287f7 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -3,8 +3,8 @@ from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, TagColumn, - ToggleColumn, + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, + TagColumn, ToggleColumn, ) from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -49,14 +49,6 @@ RACKGROUP_ELEVATIONS = """ """ -RACK_DEVICE_COUNT = """ -{{ value }} -""" - -DEVICE_COUNT = """ -{{ value|default:0 }} -""" - RACKRESERVATION_ACTIONS = """ @@ -75,14 +67,6 @@ MANUFACTURER_ACTIONS = """ {% endif %} """ -DEVICEROLE_DEVICE_COUNT = """ -{{ value|default:0 }} -""" - -DEVICEROLE_VM_COUNT = """ -{{ value|default:0 }} -""" - DEVICEROLE_ACTIONS = """ @@ -92,24 +76,12 @@ DEVICEROLE_ACTIONS = """ {% endif %} """ -PLATFORM_DEVICE_COUNT = """ -{{ value|default:0 }} -""" - -PLATFORM_VM_COUNT = """ -{{ value|default:0 }} -""" - DEVICE_PRIMARY_IP = """ {{ record.primary_ip6.address.ip|default:"" }} {% if record.primary_ip6 and record.primary_ip4 %}
    {% endif %} {{ record.primary_ip4.address.ip|default:"" }} """ -DEVICETYPE_INSTANCES_TEMPLATE = """ -{{ record.instance_count }} -""" - UTILIZATION_GRAPH = """ {% load helpers %} {% utilization_graph value %} @@ -129,10 +101,6 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} """ -POWERPANEL_POWERFEED_COUNT = """ -{{ value }} -""" - INTERFACE_IPADDRESSES = """ {% for ip in record.ip_addresses.unrestricted %} {{ ip }}
    @@ -280,8 +248,9 @@ class RackTable(BaseTable): class RackDetailTable(RackTable): - device_count = tables.TemplateColumn( - template_code=RACK_DEVICE_COUNT, + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'rack_id': 'pk'}, verbose_name='Devices' ) get_utilization = tables.TemplateColumn( @@ -388,8 +357,9 @@ class DeviceTypeTable(BaseTable): is_full_depth = BooleanColumn( verbose_name='Full Depth' ) - instance_count = tables.TemplateColumn( - template_code=DEVICETYPE_INSTANCES_TEMPLATE, + instance_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'device_type_id': 'pk'}, verbose_name='Instances' ) tags = TagColumn( @@ -526,12 +496,14 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class DeviceRoleTable(BaseTable): pk = ToggleColumn() - device_count = tables.TemplateColumn( - template_code=DEVICEROLE_DEVICE_COUNT, + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'role': 'slug'}, verbose_name='Devices' ) - vm_count = tables.TemplateColumn( - template_code=DEVICEROLE_VM_COUNT, + vm_count = LinkedCountColumn( + viewname='virtualization:virtualmachine_list', + url_params={'role': 'slug'}, verbose_name='VMs' ) color = tables.TemplateColumn( @@ -553,12 +525,14 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() - device_count = tables.TemplateColumn( - template_code=PLATFORM_DEVICE_COUNT, + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'platform': 'slug'}, verbose_name='Devices' ) - vm_count = tables.TemplateColumn( - template_code=PLATFORM_VM_COUNT, + vm_count = LinkedCountColumn( + viewname='virtualization:virtualmachine_list', + url_params={'platform': 'slug'}, verbose_name='VMs' ) actions = ButtonsColumn(Platform, pk_field='slug') @@ -994,7 +968,9 @@ class VirtualChassisTable(BaseTable): master = tables.Column( linkify=True ) - member_count = tables.Column( + member_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) tags = TagColumn( @@ -1018,8 +994,9 @@ class PowerPanelTable(BaseTable): viewname='dcim:site', args=[Accessor('site__slug')] ) - powerfeed_count = tables.TemplateColumn( - template_code=POWERPANEL_POWERFEED_COUNT, + powerfeed_count = LinkedCountColumn( + viewname='dcim:powerfeed_list', + url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) tags = TagColumn( diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index e8b2474ea..1d2ff9243 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -4,7 +4,9 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn +from utilities.tables import ( + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, +) from virtualization.models import VMInterface from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF @@ -34,14 +36,6 @@ UTILIZATION_GRAPH = """ {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %} """ -ROLE_PREFIX_COUNT = """ -{{ value|default:0 }} -""" - -ROLE_VLAN_COUNT = """ -{{ value|default:0 }} -""" - PREFIX_LINK = """ {% if record.children %} @@ -209,7 +203,9 @@ class RIRTable(BaseTable): is_private = BooleanColumn( verbose_name='Private' ) - aggregate_count = tables.Column( + aggregate_count = LinkedCountColumn( + viewname='ipam:aggregate_list', + url_params={'rir': 'slug'}, verbose_name='Aggregates' ) actions = ButtonsColumn(RIR, pk_field='slug') @@ -304,12 +300,14 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() - prefix_count = tables.TemplateColumn( - template_code=ROLE_PREFIX_COUNT, + prefix_count = LinkedCountColumn( + viewname='ipam:prefix_list', + url_params={'role': 'slug'}, verbose_name='Prefixes' ) - vlan_count = tables.TemplateColumn( - template_code=ROLE_VLAN_COUNT, + vlan_count = LinkedCountColumn( + viewname='ipam:vlan_list', + url_params={'role': 'slug'}, verbose_name='VLANs' ) actions = ButtonsColumn(Role, pk_field='slug') @@ -508,7 +506,9 @@ class VLANGroupTable(BaseTable): viewname='dcim:site', args=[Accessor('site__slug')] ) - vlan_count = tables.Column( + vlan_count = LinkedCountColumn( + viewname='ipam:vlan_list', + url_params={'group': 'slug'}, verbose_name='VLANs' ) actions = ButtonsColumn( diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 7158b0b13..345c8a689 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn from .models import SecretRole, Secret @@ -11,7 +11,9 @@ from .models import SecretRole, Secret class SecretRoleTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - secret_count = tables.Column( + secret_count = LinkedCountColumn( + viewname='secrets:secret_list', + url_params={'role': 'slug'}, verbose_name='Secrets' ) actions = ButtonsColumn(SecretRole, pk_field='slug') diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index dc96b839c..8f9073025 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn from .models import Tenant, TenantGroup MPTT_LINK = """ @@ -32,7 +32,9 @@ class TenantGroupTable(BaseTable): template_code=MPTT_LINK, orderable=False ) - tenant_count = tables.Column( + tenant_count = LinkedCountColumn( + viewname='tenancy:tenant_list', + url_params={'group': 'slug'}, verbose_name='Tenants' ) actions = ButtonsColumn(TenantGroup, pk_field='slug') diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 76c37b403..d4861c93a 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,6 +1,7 @@ import django_tables2 as tables from django.core.exceptions import FieldDoesNotExist from django.db.models.fields.related import RelatedField +from django.urls import reverse from django.utils.safestring import mark_safe from django_tables2.data import TableQuerysetData @@ -213,6 +214,29 @@ class ColoredLabelColumn(tables.TemplateColumn): super().__init__(template_code=self.template_code, *args, **kwargs) +class LinkedCountColumn(tables.Column): + """ + Render a count of related objects linked to a filtered URL. + + :param viewname: The view name to use for URL resolution + :param view_kwargs: Additional kwargs to pass for URL resolution (optional) + :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional) + """ + def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs): + self.viewname = viewname + self.view_kwargs = view_kwargs or {} + self.url_params = url_params + super().__init__(*args, default=default, **kwargs) + + def render(self, record, value): + if value: + url = reverse(self.viewname, kwargs=self.view_kwargs) + if self.url_params: + url += '?' + '&'.join([f'{k}={getattr(record, v)}' for k, v in self.url_params.items()]) + return mark_safe(f'{value}') + return value + + class TagColumn(tables.TemplateColumn): """ Display a list of tags assigned to the object. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 7cb684dee..81cf986d8 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -2,7 +2,9 @@ import django_tables2 as tables from dcim.tables import BaseInterfaceTable from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, TagColumn, ToggleColumn +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, +) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface VIRTUALMACHINE_PRIMARY_IP = """ @@ -11,14 +13,6 @@ VIRTUALMACHINE_PRIMARY_IP = """ {{ record.primary_ip4.address.ip|default:"" }} """ -DEVICE_COUNT = """ -{{ value|default:0 }} -""" - -VM_COUNT = """ -{{ value|default:0 }} -""" - # # Cluster types @@ -69,12 +63,14 @@ class ClusterTable(BaseTable): site = tables.Column( linkify=True ) - device_count = tables.TemplateColumn( - template_code=DEVICE_COUNT, + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'cluster_id': 'pk'}, verbose_name='Devices' ) - vm_count = tables.TemplateColumn( - template_code=VM_COUNT, + vm_count = LinkedCountColumn( + viewname='virtualization:virtualmachine_list', + url_params={'cluster_id': 'pk'}, verbose_name='VMs' ) tags = TagColumn( From 12e2537222139795e4a82406bed67b45f32d5650 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 25 Sep 2020 14:18:29 -0400 Subject: [PATCH 064/291] General cleanup of tables --- netbox/dcim/tables.py | 62 +++----------------------- netbox/extras/choices.py | 6 +++ netbox/extras/models/change_logging.py | 3 ++ netbox/extras/tables.py | 26 +++-------- netbox/ipam/tables.py | 21 +-------- netbox/utilities/tables.py | 2 +- netbox/virtualization/tables.py | 13 ++---- 7 files changed, 27 insertions(+), 106 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1053287f7..7af030a03 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -24,19 +24,6 @@ MPTT_LINK = """ """ -SITE_REGION_LINK = """ -{% if record.region %} - {{ record.region }} -{% else %} - — -{% endif %} -""" - -COLOR_LABEL = """ -{% load helpers %} - -""" - DEVICE_LINK = """ {{ record.name|default:'Unnamed device' }} @@ -49,39 +36,6 @@ RACKGROUP_ELEVATIONS = """ """ -RACKRESERVATION_ACTIONS = """ - - - -{% if perms.dcim.change_rackreservation %} - -{% endif %} -""" - -MANUFACTURER_ACTIONS = """ - - - -{% if perms.dcim.change_manufacturer %} - -{% endif %} -""" - -DEVICEROLE_ACTIONS = """ - - - -{% if perms.dcim.change_devicerole %} - -{% endif %} -""" - -DEVICE_PRIMARY_IP = """ -{{ record.primary_ip6.address.ip|default:"" }} -{% if record.primary_ip6 and record.primary_ip4 %}
    {% endif %} -{{ record.primary_ip4.address.ip|default:"" }} -""" - UTILIZATION_GRAPH = """ {% load helpers %} {% utilization_graph value %} @@ -149,8 +103,8 @@ class SiteTable(BaseTable): order_by=('_name',) ) status = ChoiceFieldColumn() - region = tables.TemplateColumn( - template_code=SITE_REGION_LINK + region = tables.Column( + linkify=True ) tenant = tables.TemplateColumn( template_code=COL_TENANT @@ -206,7 +160,7 @@ class RackRoleTable(BaseTable): pk = ToggleColumn() name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') - color = tables.TemplateColumn(COLOR_LABEL) + color = ColorColumn() actions = ButtonsColumn(RackRole) class Meta(BaseTable.Meta): @@ -506,10 +460,7 @@ class DeviceRoleTable(BaseTable): url_params={'role': 'slug'}, verbose_name='VMs' ) - color = tables.TemplateColumn( - template_code=COLOR_LABEL, - verbose_name='Label' - ) + color = ColorColumn() vm_role = BooleanColumn() actions = ButtonsColumn(DeviceRole, pk_field='slug') @@ -577,9 +528,8 @@ class DeviceTable(BaseTable): verbose_name='Type', text=lambda record: record.device_type.display_name ) - primary_ip = tables.TemplateColumn( - template_code=DEVICE_PRIMARY_IP, - orderable=False, + primary_ip = tables.Column( + linkify=True, verbose_name='IP Address' ) primary_ip4 = tables.Column( diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 7e1e7a036..45f8ac31f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -78,6 +78,12 @@ class ObjectChangeActionChoices(ChoiceSet): (ACTION_DELETE, 'Deleted'), ) + CSS_CLASSES = { + ACTION_CREATE: 'success', + ACTION_UPDATE: 'primary', + ACTION_DELETE: 'danger', + } + # # Log Levels for Reports and Scripts diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index bec8e2b75..d03dab00a 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -152,3 +152,6 @@ class ObjectChange(models.Model): self.object_repr, self.object_data, ) + + def get_action_class(self): + return ObjectChangeActionChoices.CSS_CLASSES.get(self.action) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 7980bdcc1..8db8f6c57 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,6 +1,7 @@ import django_tables2 as tables +from django.conf import settings -from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn from .models import ConfigContext, ObjectChange, Tag, TaggedItem TAGGED_ITEM = """ @@ -20,20 +21,6 @@ CONFIGCONTEXT_ACTIONS = """ {% endif %} """ -OBJECTCHANGE_TIME = """ -{{ value|date:"SHORT_DATETIME_FORMAT" }} -""" - -OBJECTCHANGE_ACTION = """ -{% if record.action == 'create' %} - Created -{% elif record.action == 'update' %} - Updated -{% elif record.action == 'delete' %} - Deleted -{% endif %} -""" - OBJECTCHANGE_OBJECT = """ {% if record.action != 3 and record.changed_object.get_absolute_url %} {{ record.object_repr }} @@ -91,12 +78,11 @@ class ConfigContextTable(BaseTable): class ObjectChangeTable(BaseTable): - time = tables.TemplateColumn( - template_code=OBJECTCHANGE_TIME - ) - action = tables.TemplateColumn( - template_code=OBJECTCHANGE_ACTION + time = tables.DateTimeColumn( + linkify=True, + format=settings.SHORT_DATETIME_FORMAT ) + action = ChoiceFieldColumn() changed_object_type = tables.Column( verbose_name='Type' ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 1d2ff9243..c6381a37a 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -78,14 +78,6 @@ VRF_LINK = """ {% endif %} """ -STATUS_LABEL = """ -{% if record.pk %} - {{ record.get_status_display }} -{% else %} - Available -{% endif %} -""" - VLAN_LINK = """ {% if record.pk %} {{ record.vid }} @@ -130,12 +122,6 @@ VLAN_MEMBER_TAGGED = """ {% endif %} """ -VLAN_MEMBER_ACTIONS = """ -{% if perms.dcim.change_interface %} - -{% endif %} -""" - TENANT_LINK = """ {% if record.tenant %} {{ record.tenant }} @@ -587,15 +573,11 @@ class VLANMembersTable(BaseTable): template_code=VLAN_MEMBER_TAGGED, orderable=False ) - actions = tables.TemplateColumn( - template_code=VLAN_MEMBER_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) class VLANDevicesTable(VLANMembersTable): device = tables.LinkColumn() + actions = ButtonsColumn(Interface, buttons=['edit']) class Meta(BaseTable.Meta): model = Interface @@ -604,6 +586,7 @@ class VLANDevicesTable(VLANMembersTable): class VLANVirtualMachinesTable(VLANMembersTable): virtual_machine = tables.LinkColumn() + actions = ButtonsColumn(VMInterface, buttons=['edit']) class Meta(BaseTable.Meta): model = VMInterface diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index d4861c93a..ff19bb4dc 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -197,7 +197,7 @@ class ColorColumn(tables.Column): """ def render(self, value): return mark_safe( - ' '.format(value) + f' ' ) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 81cf986d8..7acae4cc0 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -7,12 +7,6 @@ from utilities.tables import ( ) from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface -VIRTUALMACHINE_PRIMARY_IP = """ -{{ record.primary_ip6.address.ip|default:"" }} -{% if record.primary_ip6 and record.primary_ip4 %}
    {% endif %} -{{ record.primary_ip4.address.ip|default:"" }} -""" - # # Cluster types @@ -113,10 +107,9 @@ class VirtualMachineDetailTable(VirtualMachineTable): linkify=True, verbose_name='IPv6 Address' ) - primary_ip = tables.TemplateColumn( - orderable=False, - verbose_name='IP Address', - template_code=VIRTUALMACHINE_PRIMARY_IP + primary_ip = tables.Column( + linkify=True, + verbose_name='IP Address' ) tags = TagColumn( url_name='virtualization:virtualmachine_list' From 587e6fcf72fe05a7597b8e565b782f659aacefb1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 Sep 2020 15:07:56 -0400 Subject: [PATCH 065/291] Initial work on cable paths (WIP) --- netbox/dcim/fields.py | 11 +++ netbox/dcim/managers.py | 8 ++ netbox/dcim/migrations/0120_cablepath.py | 27 +++++++ netbox/dcim/models/__init__.py | 1 + netbox/dcim/models/device_components.py | 23 ++++-- netbox/dcim/models/devices.py | 42 ++++++++++ netbox/dcim/signals.py | 97 ++++++++++++++---------- netbox/dcim/utils.py | 65 ++++++++++++++++ netbox/templates/dcim/inc/interface.html | 66 +++------------- 9 files changed, 239 insertions(+), 101 deletions(-) create mode 100644 netbox/dcim/managers.py create mode 100644 netbox/dcim/migrations/0120_cablepath.py create mode 100644 netbox/dcim/utils.py diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 3acd0d4a1..23b9b7fd5 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,3 +1,5 @@ +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.validators import ArrayMaxLengthValidator from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models @@ -50,3 +52,12 @@ class MACAddressField(models.Field): if not value: return None return str(self.to_python(value)) + + +class PathField(ArrayField): + """ + An ArrayField which holds a set of objects, each identified by a (type, ID) tuple. + """ + def __init__(self, **kwargs): + kwargs['base_field'] = models.CharField(max_length=40) + super().__init__(**kwargs) diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py new file mode 100644 index 000000000..903a6feac --- /dev/null +++ b/netbox/dcim/managers.py @@ -0,0 +1,8 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Manager + + +class CablePathManager(Manager): + + def create_for_endpoint(self, endpoint): + ct = ContentType.objects.get_for_model(endpoint) diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py new file mode 100644 index 000000000..4e36c31d0 --- /dev/null +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1 on 2020-09-30 18:09 + +import dcim.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0119_inventoryitem_mptt_rebuild'), + ] + + operations = [ + migrations.CreateModel( + name='CablePath', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('origin_id', models.PositiveIntegerField()), + ('destination_id', models.PositiveIntegerField(blank=True, null=True)), + ('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)), + ('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ], + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index e50fa2eda..fdd4d1bf5 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -8,6 +8,7 @@ from .sites import * __all__ = ( 'BaseInterface', 'Cable', + 'CablePath', 'CableTermination', 'ConsolePort', 'ConsolePortTemplate', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 2cc4c8b60..12bb224fd 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,6 +1,7 @@ import logging from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -32,6 +33,7 @@ __all__ = ( 'FrontPort', 'Interface', 'InventoryItem', + 'PathEndpoint', 'PowerOutlet', 'PowerPort', 'RearPort', @@ -250,12 +252,23 @@ class CableTermination(models.Model): return endpoints +class PathEndpoint: + + def get_connections(self): + from dcim.models import CablePath + return CablePath.objects.filter( + origin_type=ContentType.objects.get_for_model(self), + origin_id=self.pk, + destination_id__isnull=False + ) + + # # Console ports # @extras_features('export_templates', 'webhooks') -class ConsolePort(CableTermination, ComponentModel): +class ConsolePort(CableTermination, PathEndpoint, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -303,7 +316,7 @@ class ConsolePort(CableTermination, ComponentModel): # @extras_features('webhooks') -class ConsoleServerPort(CableTermination, ComponentModel): +class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -344,7 +357,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): # @extras_features('export_templates', 'webhooks') -class PowerPort(CableTermination, ComponentModel): +class PowerPort(CableTermination, PathEndpoint, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -493,7 +506,7 @@ class PowerPort(CableTermination, ComponentModel): # @extras_features('webhooks') -class PowerOutlet(CableTermination, ComponentModel): +class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -585,7 +598,7 @@ class BaseInterface(models.Model): @extras_features('export_templates', 'webhooks') -class Interface(CableTermination, ComponentModel, BaseInterface): +class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 463b1a3e3..162dcb831 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -14,6 +14,9 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * +from dcim.fields import PathField +from dcim.managers import CablePathManager +from dcim.utils import path_node_to_object from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.choices import ColorChoices @@ -25,6 +28,7 @@ from .device_components import * __all__ = ( 'Cable', + 'CablePath', 'Device', 'DeviceRole', 'DeviceType', @@ -1154,6 +1158,44 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] +class CablePath(models.Model): + """ + An array of objects conveying the end-to-end path of one or more Cables. + """ + origin_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+' + ) + origin_id = models.PositiveIntegerField() + origin = GenericForeignKey( + ct_field='origin_type', + fk_field='origin_id' + ) + destination_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + destination_id = models.PositiveIntegerField( + blank=True, + null=True + ) + destination = GenericForeignKey( + ct_field='destination_type', + fk_field='destination_id' + ) + path = PathField() + + objects = CablePathManager() + + def __str__(self): + path = ', '.join([str(path_node_to_object(node)) for node in self.path]) + return f"Path #{self.pk}: {self.origin} to {self.destination} via ({path})" + + # # Virtual chassis # diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 172c366b5..4e6cadb28 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,10 +1,34 @@ import logging +from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, pre_delete +from django.db import transaction from django.dispatch import receiver -from .choices import CableStatusChoices -from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis +from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis +from .utils import object_to_path_node, trace_paths + + +def create_cablepaths(node): + """ + Create CablePaths for all paths originating from the specified node. + """ + for path, destination in trace_paths(node): + cp = CablePath(origin=node, path=path, destination=destination) + cp.save() + + +def rebuild_paths(obj): + """ + Rebuild all CablePaths which traverse the specified node + """ + node = object_to_path_node(obj) + cable_paths = CablePath.objects.filter(path__contains=[node]) + + with transaction.atomic(): + for cp in cable_paths: + cp.delete() + create_cablepaths(cp.origin) @receiver(post_save, sender=VirtualChassis) @@ -32,7 +56,7 @@ def clear_virtualchassis_members(instance, **kwargs): @receiver(post_save, sender=Cable) -def update_connected_endpoints(instance, **kwargs): +def update_connected_endpoints(instance, created, **kwargs): """ When a Cable is saved, check for and update its two connected endpoints """ @@ -40,38 +64,25 @@ def update_connected_endpoints(instance, **kwargs): # Cache the Cable on its two termination points if instance.termination_a.cable != instance: - logger.debug("Updating termination A for cable {}".format(instance)) + logger.debug(f"Updating termination A for cable {instance}") instance.termination_a.cable = instance instance.termination_a.save() if instance.termination_b.cable != instance: - logger.debug("Updating termination B for cable {}".format(instance)) + logger.debug(f"Updating termination B for cable {instance}") instance.termination_b.cable = instance instance.termination_b.save() - # Update any endpoints for this Cable. - endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() - for endpoint in endpoints: - path, split_ends, position_stack = endpoint.trace() - # Determine overall path status (connected or planned) - path_status = True - for segment in path: - if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED: - path_status = False - break - - endpoint_a = path[0][0] - endpoint_b = path[-1][2] if not split_ends and not position_stack else None - - # Patch panel ports are not connected endpoints, all other cable terminations are - if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \ - isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)): - logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) - endpoint_a.connected_endpoint = endpoint_b - endpoint_a.connection_status = path_status - endpoint_a.save() - endpoint_b.connected_endpoint = endpoint_a - endpoint_b.connection_status = path_status - endpoint_b.save() + # Create/update cable paths + if created: + for termination in (instance.termination_a, instance.termination_b): + if isinstance(termination, PathEndpoint): + create_cablepaths(termination) + else: + rebuild_paths(termination) + else: + # We currently don't support modifying either termination of an existing Cable. This + # may change in the future. + pass @receiver(pre_delete, sender=Cable) @@ -81,22 +92,28 @@ def nullify_connected_endpoints(instance, **kwargs): """ logger = logging.getLogger('netbox.dcim.cable') - endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() - # Disassociate the Cable from its termination points if instance.termination_a is not None: - logger.debug("Nullifying termination A for cable {}".format(instance)) + logger.debug(f"Nullifying termination A for cable {instance}") instance.termination_a.cable = None instance.termination_a.save() if instance.termination_b is not None: - logger.debug("Nullifying termination B for cable {}".format(instance)) + logger.debug(f"Nullifying termination B for cable {instance}") instance.termination_b.cable = None instance.termination_b.save() - # If this Cable was part of any complete end-to-end paths, tear them down. - for endpoint in endpoints: - logger.debug(f"Removing path information for {endpoint}") - if hasattr(endpoint, 'connected_endpoint'): - endpoint.connected_endpoint = None - endpoint.connection_status = None - endpoint.save() + # Delete any dependent cable paths + cable_paths = CablePath.objects.filter(path__contains=[object_to_path_node(instance)]) + retrace_queue = [cp.origin for cp in cable_paths] + deleted, _ = cable_paths.delete() + logger.info(f'Deleted {deleted} cable paths') + + # Retrace cable paths from the origins of deleted paths + for origin in retrace_queue: + # Delete and recreate all CablePaths for this origin point + # TODO: We can probably be smarter about skipping unchanged paths + CablePath.objects.filter( + origin_type=ContentType.objects.get_for_model(origin), + origin_id=origin.pk + ).delete() + create_cablepaths(origin) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py new file mode 100644 index 000000000..2baa91622 --- /dev/null +++ b/netbox/dcim/utils.py @@ -0,0 +1,65 @@ +from django.contrib.contenttypes.models import ContentType + +from .models import FrontPort, RearPort + + +def object_to_path_node(obj): + return f'{obj._meta.model_name}:{obj.pk}' + + +def objects_to_path(*obj_list): + return [object_to_path_node(obj) for obj in obj_list] + + +def path_node_to_object(repr): + model_name, object_id = repr.split(':') + model_class = ContentType.objects.get(model=model_name).model_class() + return model_class.objects.get(pk=int(object_id)) + + +def trace_paths(node): + destination = None + path = [] + position_stack = [] + + if node.cable is None: + return [] + + while node.cable is not None: + + # Follow the cable to its far-end termination + path.append(object_to_path_node(node.cable)) + peer_termination = node.get_cable_peer() + + # Follow a FrontPort to its corresponding RearPort + if isinstance(peer_termination, FrontPort): + path.append(object_to_path_node(peer_termination)) + position_stack.append(peer_termination.rear_port_position) + node = peer_termination.rear_port + path.append(object_to_path_node(node)) + + # Follow a RearPort to its corresponding FrontPort + elif isinstance(peer_termination, RearPort): + path.append(object_to_path_node(peer_termination)) + if position_stack: + position = position_stack.pop() + node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position) + path.append(object_to_path_node(node)) + else: + # No position indicated, so we have to trace _all_ peer FrontPorts + paths = [] + for frontport in FrontPort.objects.filter(rear_port=peer_termination): + branches = trace_paths(frontport) + if branches: + for branch, destination in branches: + paths.append(([*path, object_to_path_node(frontport), *branch], destination)) + else: + paths.append(([*path, object_to_path_node(frontport)], None)) + return paths + + # Anything else marks the end of the path + else: + destination = peer_termination + break + + return [(path, destination)] diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index a317dc937..706801dd1 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -75,65 +75,19 @@ Virtual interface {% elif iface.is_wireless %} Wireless interface - {% elif iface.connected_endpoint.name %} - {# Connected to an Interface #} - - - {{ iface.connected_endpoint.device }} - - - - - - {{ iface.connected_endpoint }} - - - - {% elif iface.connected_endpoint.term_side %} - {# Connected to a CircuitTermination #} - {% with iface.connected_endpoint.get_peer_termination as peer_termination %} - {% if peer_termination %} - {% if peer_termination.connected_endpoint %} - - - {{ peer_termination.connected_endpoint.device }} -
    - via - - {{ iface.connected_endpoint.circuit.provider }} - {{ iface.connected_endpoint.circuit }} - - - - - {{ peer_termination.connected_endpoint }} - - {% else %} - - - {{ peer_termination.site }} - - via - - {{ iface.connected_endpoint.circuit.provider }} - {{ iface.connected_endpoint.circuit }} - - - {% endif %} + {% else %} + {% with path_count=iface.get_connections.count %} + {% if path_count > 1 %} + Multiple connections + {% elif path_count %} + {% with endpoint=iface.get_connections.first.destination %} + {{ endpoint.parent }} + {{ endpoint }} + {% endwith %} {% else %} - - - - {{ iface.connected_endpoint.circuit.provider }} - {{ iface.connected_endpoint.circuit }} - - + Not connected {% endif %} {% endwith %} - {% else %} - - Not connected - {% endif %} {# Buttons #} From 985197788b0039976299de1fc56be25d90756dd8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 Sep 2020 15:13:33 -0400 Subject: [PATCH 066/291] Add initial tests --- netbox/dcim/tests/test_cablepaths.py | 210 +++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 netbox/dcim/tests/test_cablepaths.py diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py new file mode 100644 index 000000000..efd3fcbee --- /dev/null +++ b/netbox/dcim/tests/test_cablepaths.py @@ -0,0 +1,210 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from circuits.models import * +from dcim.models import * +from dcim.utils import objects_to_path + + +class CablePathTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + + # Create a single device that will hold all components + site = Site.objects.create(name='Site', slug='site') + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') + device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') + device = Device.objects.create(site=site, device_type=device_type, device_role=device_role, name='Test Device') + + # Create 16 instances of each type of path-terminating component + cls.console_ports = [ + ConsolePort(device=device, name=f'Console Port {i}') + for i in range(1, 17) + ] + ConsolePort.objects.bulk_create(cls.console_ports) + cls.console_server_ports = [ + ConsoleServerPort(device=device, name=f'Console Server Port {i}') + for i in range(1, 17) + ] + ConsoleServerPort.objects.bulk_create(cls.console_server_ports) + cls.power_ports = [ + PowerPort(device=device, name=f'Power Port {i}') + for i in range(1, 17) + ] + PowerPort.objects.bulk_create(cls.power_ports) + cls.power_outlets = [ + PowerOutlet(device=device, name=f'Power Outlet {i}') + for i in range(1, 17) + ] + PowerOutlet.objects.bulk_create(cls.power_outlets) + cls.interfaces = [ + Interface(device=device, name=f'Interface {i}') + for i in range(1, 17) + ] + Interface.objects.bulk_create(cls.interfaces) + + # Create four RearPorts with four FrontPorts each + cls.rear_ports = [ + RearPort(device=device, name=f'RP{i}', positions=4) for i in range(1, 5) + ] + RearPort.objects.bulk_create(cls.rear_ports) + cls.front_ports = [] + for i, rear_port in enumerate(cls.rear_ports, start=1): + cls.front_ports.extend( + FrontPort(device=device, name=f'FP{i}:{j}', rear_port=rear_port, rear_port_position=j) + for j in range(1, 5) + ) + FrontPort.objects.bulk_create(cls.front_ports) + + # Create four circuits with two terminations (A and Z) each (8 total) + provider = Provider.objects.create(name='Provider', slug='provider') + circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') + circuits = [ + Circuit(provider=provider, type=circuit_type, cid=f'Circuit {i}') for i in range(1, 5) + ] + Circuit.objects.bulk_create(circuits) + cls.circuit_terminations = [ + *[CircuitTermination(circuit=circuit, site=site, term_side='A', port_speed=1000) for circuit in circuits], + *[CircuitTermination(circuit=circuit, site=site, term_side='Z', port_speed=1000) for circuit in circuits], + ] + CircuitTermination.objects.bulk_create(cls.circuit_terminations) + + def assertPathExists(self, origin, destination, path=None, msg=None): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. + + :param origin: Originating endpoint + :param destination: Terminating endpoint, or None + :param path: Sequence of objects comprising the intermediate path (optional) + :param msg: Custom failure message (optional) + """ + kwargs = { + 'origin_type': ContentType.objects.get_for_model(origin), + 'origin_id': origin.pk, + } + if destination is not None: + kwargs['destination_type'] = ContentType.objects.get_for_model(destination) + kwargs['destination_id'] = destination.pk + else: + kwargs['destination_type__isnull'] = True + kwargs['destination_id__isnull'] = True + if path is not None: + kwargs['path'] = objects_to_path(*path) + if msg is None: + if destination is not None: + msg = f"Missing path from {origin} to {destination}" + else: + msg = f"Missing partial path originating from {origin}" + self.assertEqual(CablePath.objects.filter(**kwargs).count(), 1, msg=msg) + + def test_01_interface_to_interface(self): + """ + [IF1] --C1-- [IF2] + """ + # Create cable 1 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.interfaces[1]) + cable1.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[1], + path=(cable1,) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[0], + path=(cable1,) + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 1 + cable1.delete() + self.assertEqual(CablePath.objects.count(), 0) + + def test_02_interface_to_interface_via_single_frontport(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C2-- [IF2] + """ + # Create cable 1 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[0], self.rear_ports[0]) + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 2 + cable2 = Cable(termination_a=self.rear_ports[0], termination_b=self.interfaces[1]) + cable2.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[1], + path=(cable1, self.front_ports[0], self.rear_ports[0], cable2) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[0], + path=(cable2, self.rear_ports[0], self.front_ports[0], cable1) + ) + self.assertEqual(CablePath.objects.count(), 5) # Two complete + three partial paths + + # Delete cable 1 + cable1.delete() + self.assertPathExists( + origin=self.interfaces[1], + destination=None, + path=(cable2, self.rear_ports[0], self.front_ports[0]) + ) + self.assertEqual(CablePath.objects.count(), 4) # Four partial paths from IF2 to FP1:[1-4] + + def test_03_interface_to_interface_via_rearport_pair(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP2] [FP2:1] --C3-- [IF2] + """ + # Create cable 1 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[0], self.rear_ports[0]) + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 2 + cable2 = Cable(termination_a=self.rear_ports[0], termination_b=self.rear_ports[1]) + cable2.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[0], self.rear_ports[0], cable2, self.rear_ports[1], self.front_ports[4]) + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 3 + cable3 = Cable(termination_a=self.front_ports[4], termination_b=self.interfaces[1]) + cable3.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[1], + path=( + cable1, self.front_ports[0], self.rear_ports[0], cable2, self.rear_ports[1], self.front_ports[4], + cable3, + ) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[0], + path=( + cable3, self.front_ports[4], self.rear_ports[1], cable2, self.rear_ports[0], self.front_ports[0], + cable1 + ) + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + self.assertEqual(CablePath.objects.count(), 2) # Two partial paths from IF1 and IF2 From 319329e2b230a65122031130d861816cf3f75160 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 Sep 2020 16:17:22 -0400 Subject: [PATCH 067/291] Extend cable path tests --- netbox/dcim/tests/test_cablepaths.py | 275 ++++++++++++++++++++------- 1 file changed, 204 insertions(+), 71 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index efd3fcbee..401c38ca5 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -120,91 +120,224 @@ class CablePathTestCase(TestCase): # Delete cable 1 cable1.delete() + + # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_02_interface_to_interface_via_single_frontport(self): + def test_02_interfaces_to_interface_via_pass_through(self): """ - [IF1] --C1-- [FP1:1] [RP1] --C2-- [IF2] + [IF1] --C1-- [FP1:1] [RP1] --C3-- [IF3] + [IF2] --C2-- [FP1:2] """ - # Create cable 1 + # Create cables 1 and 2 cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) cable1.save() + cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + cable2.save() self.assertPathExists( origin=self.interfaces[0], destination=None, path=(cable1, self.front_ports[0], self.rear_ports[0]) ) - self.assertEqual(CablePath.objects.count(), 1) - - # Create cable 2 - cable2 = Cable(termination_a=self.rear_ports[0], termination_b=self.interfaces[1]) - cable2.save() - self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[1], - path=(cable1, self.front_ports[0], self.rear_ports[0], cable2) - ) - self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[0], - path=(cable2, self.rear_ports[0], self.front_ports[0], cable1) - ) - self.assertEqual(CablePath.objects.count(), 5) # Two complete + three partial paths - - # Delete cable 1 - cable1.delete() self.assertPathExists( origin=self.interfaces[1], destination=None, - path=(cable2, self.rear_ports[0], self.front_ports[0]) - ) - self.assertEqual(CablePath.objects.count(), 4) # Four partial paths from IF2 to FP1:[1-4] - - def test_03_interface_to_interface_via_rearport_pair(self): - """ - [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP2] [FP2:1] --C3-- [IF2] - """ - # Create cable 1 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) - cable1.save() - self.assertPathExists( - origin=self.interfaces[0], - destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0]) - ) - self.assertEqual(CablePath.objects.count(), 1) - - # Create cable 2 - cable2 = Cable(termination_a=self.rear_ports[0], termination_b=self.rear_ports[1]) - cable2.save() - self.assertPathExists( - origin=self.interfaces[0], - destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0], cable2, self.rear_ports[1], self.front_ports[4]) - ) - self.assertEqual(CablePath.objects.count(), 1) - - # Create cable 3 - cable3 = Cable(termination_a=self.front_ports[4], termination_b=self.interfaces[1]) - cable3.save() - self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[1], - path=( - cable1, self.front_ports[0], self.rear_ports[0], cable2, self.rear_ports[1], self.front_ports[4], - cable3, - ) - ) - self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[0], - path=( - cable3, self.front_ports[4], self.rear_ports[1], cable2, self.rear_ports[0], self.front_ports[0], - cable1 - ) + path=(cable2, self.front_ports[1], self.rear_ports[0]) ) self.assertEqual(CablePath.objects.count(), 2) - # Delete cable 2 - cable2.delete() - self.assertEqual(CablePath.objects.count(), 2) # Two partial paths from IF1 and IF2 + # Create cable 3 + cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.interfaces[2]) + cable3.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[2], + path=(cable1, self.front_ports[0], self.rear_ports[0], cable3) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[2], + path=(cable2, self.front_ports[1], self.rear_ports[0], cable3) + ) + self.assertPathExists( + origin=self.interfaces[2], + destination=self.interfaces[0], + path=(cable3, self.rear_ports[0], self.front_ports[0], cable1) + ) + self.assertPathExists( + origin=self.interfaces[2], + destination=self.interfaces[1], + path=(cable3, self.rear_ports[0], self.front_ports[1], cable2) + ) + self.assertEqual(CablePath.objects.count(), 6) # Four complete + two partial paths + + # Delete cable 3 + cable3.delete() + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[0], self.rear_ports[0]) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=None, + path=(cable2, self.front_ports[1], self.rear_ports[0]) + ) + + # Check for two partial paths from IF1 and IF2 + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + + def test_03_interfaces_to_interfaces_via_pass_through(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] + [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] + """ + # Create cables 1-2 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1.save() + cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + cable2.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[0], self.rear_ports[0]) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=None, + path=(cable2, self.front_ports[1], self.rear_ports[0]) + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.rear_ports[1]) + cable3.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4]) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=None, + path=(cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5]) + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cables 4-5 + cable4 = Cable(termination_a=self.front_ports[4], termination_b=self.interfaces[2]) + cable4.save() + cable5 = Cable(termination_a=self.front_ports[5], termination_b=self.interfaces[3]) + cable5.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[2], + path=( + cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4], + cable4, + ) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[3], + path=( + cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5], + cable5, + ) + ) + self.assertPathExists( + origin=self.interfaces[2], + destination=self.interfaces[0], + path=( + cable4, self.front_ports[4], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[0], + cable1 + ) + ) + self.assertPathExists( + origin=self.interfaces[3], + destination=self.interfaces[1], + path=( + cable5, self.front_ports[5], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[1], + cable2 + ) + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 3 + cable3.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + + def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2:1] [RP2] --C4-- [RP3] [FP3:1] --C5-- [RP4] [FP4:1] --C6-- [IF3] + [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] + """ + # Create cables 1-2, 6-7 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1.save() + cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + cable2.save() + cable6 = Cable(termination_a=self.interfaces[2], termination_b=self.front_ports[12]) + cable6.save() + cable7 = Cable(termination_a=self.interfaces[3], termination_b=self.front_ports[13]) + cable7.save() + self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface + + # Create cables 3 and 5 + cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.front_ports[4]) + cable3.save() + cable5 = Cable(termination_a=self.rear_ports[3], termination_b=self.front_ports[8]) + cable5.save() + self.assertEqual(CablePath.objects.count(), 4) # Four (longer) partial paths; one from each interface + + # Create cable 4 + cable4 = Cable(termination_a=self.rear_ports[1], termination_b=self.rear_ports[2]) + cable4.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[2], + path=( + cable1, self.front_ports[0], self.rear_ports[0], cable3, self.front_ports[4], self.rear_ports[1], + cable4, self.rear_ports[2], self.front_ports[8], cable5, self.rear_ports[3], self.front_ports[12], + cable6 + ) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[3], + path=( + cable2, self.front_ports[1], self.rear_ports[0], cable3, self.front_ports[4], self.rear_ports[1], + cable4, self.rear_ports[2], self.front_ports[8], cable5, self.rear_ports[3], self.front_ports[13], + cable7 + ) + ) + self.assertPathExists( + origin=self.interfaces[2], + destination=self.interfaces[0], + path=( + cable6, self.front_ports[12], self.rear_ports[3], cable5, self.front_ports[8], self.rear_ports[2], + cable4, self.rear_ports[1], self.front_ports[4], cable3, self.rear_ports[0], self.front_ports[0], + cable1 + ) + ) + self.assertPathExists( + origin=self.interfaces[3], + destination=self.interfaces[1], + path=( + cable7, self.front_ports[13], self.rear_ports[3], cable5, self.front_ports[8], self.rear_ports[2], + cable4, self.rear_ports[1], self.front_ports[4], cable3, self.rear_ports[0], self.front_ports[1], + cable2 + ) + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 3 + cable3.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) From cd7179937376ea7c7b81deedf37509717ed85143 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 Sep 2020 17:09:39 -0400 Subject: [PATCH 068/291] Ignore the position stack when traversing single-position rear ports --- netbox/dcim/utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 2baa91622..75029cacc 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -34,14 +34,18 @@ def trace_paths(node): # Follow a FrontPort to its corresponding RearPort if isinstance(peer_termination, FrontPort): path.append(object_to_path_node(peer_termination)) - position_stack.append(peer_termination.rear_port_position) node = peer_termination.rear_port + if node.positions > 1: + position_stack.append(peer_termination.rear_port_position) path.append(object_to_path_node(node)) # Follow a RearPort to its corresponding FrontPort elif isinstance(peer_termination, RearPort): path.append(object_to_path_node(peer_termination)) - if position_stack: + if peer_termination.positions == 1: + node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=1) + path.append(object_to_path_node(node)) + elif position_stack: position = position_stack.pop() node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position) path.append(object_to_path_node(node)) From e53ae1d584267cd2a40e0ae701e150334364e10d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 Sep 2020 17:10:22 -0400 Subject: [PATCH 069/291] Extend cable path tests --- netbox/dcim/tests/test_cablepaths.py | 97 +++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 401c38ca5..767b34594 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -45,17 +45,36 @@ class CablePathTestCase(TestCase): ] Interface.objects.bulk_create(cls.interfaces) - # Create four RearPorts with four FrontPorts each + # Create four RearPorts with four FrontPorts each, and two with only one position cls.rear_ports = [ - RearPort(device=device, name=f'RP{i}', positions=4) for i in range(1, 5) + RearPort(device=device, name=f'RP1', positions=4), + RearPort(device=device, name=f'RP2', positions=4), + RearPort(device=device, name=f'RP3', positions=4), + RearPort(device=device, name=f'RP4', positions=4), + RearPort(device=device, name=f'RP5', positions=1), + RearPort(device=device, name=f'RP6', positions=1), ] RearPort.objects.bulk_create(cls.rear_ports) - cls.front_ports = [] - for i, rear_port in enumerate(cls.rear_ports, start=1): - cls.front_ports.extend( - FrontPort(device=device, name=f'FP{i}:{j}', rear_port=rear_port, rear_port_position=j) - for j in range(1, 5) - ) + cls.front_ports = [ + FrontPort(device=device, name=f'FP1:1', rear_port=cls.rear_ports[0], rear_port_position=1), + FrontPort(device=device, name=f'FP1:2', rear_port=cls.rear_ports[0], rear_port_position=2), + FrontPort(device=device, name=f'FP1:3', rear_port=cls.rear_ports[0], rear_port_position=3), + FrontPort(device=device, name=f'FP1:4', rear_port=cls.rear_ports[0], rear_port_position=4), + FrontPort(device=device, name=f'FP2:1', rear_port=cls.rear_ports[1], rear_port_position=1), + FrontPort(device=device, name=f'FP2:2', rear_port=cls.rear_ports[1], rear_port_position=2), + FrontPort(device=device, name=f'FP2:3', rear_port=cls.rear_ports[1], rear_port_position=3), + FrontPort(device=device, name=f'FP2:4', rear_port=cls.rear_ports[1], rear_port_position=4), + FrontPort(device=device, name=f'FP3:1', rear_port=cls.rear_ports[2], rear_port_position=1), + FrontPort(device=device, name=f'FP3:2', rear_port=cls.rear_ports[2], rear_port_position=2), + FrontPort(device=device, name=f'FP3:3', rear_port=cls.rear_ports[2], rear_port_position=3), + FrontPort(device=device, name=f'FP3:4', rear_port=cls.rear_ports[2], rear_port_position=4), + FrontPort(device=device, name=f'FP4:1', rear_port=cls.rear_ports[3], rear_port_position=1), + FrontPort(device=device, name=f'FP4:2', rear_port=cls.rear_ports[3], rear_port_position=2), + FrontPort(device=device, name=f'FP4:3', rear_port=cls.rear_ports[3], rear_port_position=3), + FrontPort(device=device, name=f'FP4:4', rear_port=cls.rear_ports[3], rear_port_position=4), + FrontPort(device=device, name=f'FP5', rear_port=cls.rear_ports[4], rear_port_position=1), + FrontPort(device=device, name=f'FP6', rear_port=cls.rear_ports[5], rear_port_position=1), + ] FrontPort.objects.bulk_create(cls.front_ports) # Create four circuits with two terminations (A and Z) each (8 total) @@ -341,3 +360,65 @@ class CablePathTestCase(TestCase): # Check for four partial paths; one from each interface self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + + def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP5] [RP5] --C4-- [RP2] [FP2:1] --C5-- [IF3] + [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] + """ + # Create cables 1-2, 5-6 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) # IF1 -> FP1:1 + cable1.save() + cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) # IF2 -> FP1:2 + cable2.save() + cable5 = Cable(termination_a=self.interfaces[2], termination_b=self.front_ports[4]) # IF3 -> FP2:1 + cable5.save() + cable6 = Cable(termination_a=self.interfaces[3], termination_b=self.front_ports[5]) # IF4 -> FP2:2 + cable6.save() + self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface + + # Create cables 3-4 + cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.front_ports[16]) # RP1 -> FP5 + cable3.save() + cable4 = Cable(termination_a=self.rear_ports[4], termination_b=self.rear_ports[1]) # RP5 -> RP2 + cable4.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[2], + path=( + cable1, self.front_ports[0], self.rear_ports[0], cable3, self.front_ports[16], self.rear_ports[4], + cable4, self.rear_ports[1], self.front_ports[4], cable5 + ) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[3], + path=( + cable2, self.front_ports[1], self.rear_ports[0], cable3, self.front_ports[16], self.rear_ports[4], + cable4, self.rear_ports[1], self.front_ports[5], cable6 + ) + ) + self.assertPathExists( + origin=self.interfaces[2], + destination=self.interfaces[0], + path=( + cable5, self.front_ports[4], self.rear_ports[1], cable4, self.rear_ports[4], self.front_ports[16], + cable3, self.rear_ports[0], self.front_ports[0], cable1 + ) + ) + self.assertPathExists( + origin=self.interfaces[3], + destination=self.interfaces[1], + path=( + cable6, self.front_ports[5], self.rear_ports[1], cable4, self.rear_ports[4], self.front_ports[16], + cable3, self.rear_ports[0], self.front_ports[1], cable2 + ) + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 3 + cable3.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) From 46df5a97b22bfd4f3d5bb24525c346f78194f3c6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 30 Sep 2020 17:12:38 -0400 Subject: [PATCH 070/291] Remove extraneous test objects --- netbox/dcim/tests/test_cablepaths.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 767b34594..3d4efae8e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -18,27 +18,7 @@ class CablePathTestCase(TestCase): device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') device = Device.objects.create(site=site, device_type=device_type, device_role=device_role, name='Test Device') - # Create 16 instances of each type of path-terminating component - cls.console_ports = [ - ConsolePort(device=device, name=f'Console Port {i}') - for i in range(1, 17) - ] - ConsolePort.objects.bulk_create(cls.console_ports) - cls.console_server_ports = [ - ConsoleServerPort(device=device, name=f'Console Server Port {i}') - for i in range(1, 17) - ] - ConsoleServerPort.objects.bulk_create(cls.console_server_ports) - cls.power_ports = [ - PowerPort(device=device, name=f'Power Port {i}') - for i in range(1, 17) - ] - PowerPort.objects.bulk_create(cls.power_ports) - cls.power_outlets = [ - PowerOutlet(device=device, name=f'Power Outlet {i}') - for i in range(1, 17) - ] - PowerOutlet.objects.bulk_create(cls.power_outlets) + # Create 16 interfaces for testing cls.interfaces = [ Interface(device=device, name=f'Interface {i}') for i in range(1, 17) From 19a3a4d4ef2b6d5c974c66a7a6babcacf8ce7fce Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 11:30:03 -0400 Subject: [PATCH 071/291] Add GenericRelation to originating cable paths on PathEndpoint --- netbox/dcim/models/device_components.py | 20 ++++++----- netbox/dcim/views.py | 18 ++++++---- netbox/templates/dcim/inc/consoleport.html | 13 +------- .../templates/dcim/inc/consoleserverport.html | 13 +------- .../dcim/inc/endpoint_connection.html | 10 ++++++ netbox/templates/dcim/inc/interface.html | 13 +------- netbox/templates/dcim/inc/poweroutlet.html | 33 ++++++++----------- netbox/templates/dcim/inc/powerport.html | 17 +--------- 8 files changed, 50 insertions(+), 87 deletions(-) create mode 100644 netbox/templates/dcim/inc/endpoint_connection.html diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 12bb224fd..56e8f6fc4 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -252,15 +252,19 @@ class CableTermination(models.Model): return endpoints -class PathEndpoint: +class PathEndpoint(models.Model): + """ + Any object which may serve as either endpoint of a CablePath. + """ + paths = GenericRelation( + to='dcim.CablePath', + content_type_field='origin_type', + object_id_field='origin_id', + related_query_name='%(class)s' + ) - def get_connections(self): - from dcim.models import CablePath - return CablePath.objects.filter( - origin_type=ContentType.objects.get_for_model(self), - origin_id=self.pk, - destination_id__isnull=False - ) + class Meta: + abstract = True # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ce204bee0..58be5d213 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -32,7 +32,7 @@ from . import filters, forms, tables from .choices import DeviceFaceChoices from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, @@ -1018,32 +1018,36 @@ class DeviceView(ObjectView): # Console ports consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - 'connected_endpoint__device', 'cable', + Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + 'cable', ) # Console server ports consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( device=device ).prefetch_related( - 'connected_endpoint__device', 'cable', + Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + 'cable', ) # Power ports powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - '_connected_poweroutlet__device', 'cable', + Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + 'cable', ) # Power outlets poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - 'connected_endpoint__device', 'cable', 'power_port', + Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + 'cable', 'power_port', ) # Interfaces interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related( + Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), - 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', - 'cable__termination_a', 'cable__termination_b', 'tags' + 'lag', 'cable', 'tags', ) # Front ports diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 6fa5e8b91..dc0ff384c 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -36,18 +36,7 @@ {# Connection #} - {% if cp.connected_endpoint %} - - {{ cp.connected_endpoint.device }} - - - {{ cp.connected_endpoint }} - - {% else %} - - Not connected - - {% endif %} + {% include 'dcim/inc/endpoint_connection.html' with paths=cp.paths.all %} {# Actions #} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index fca1fa5f4..0af64b4c1 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -38,18 +38,7 @@ {# Connection #} - {% if csp.connected_endpoint %} - - {{ csp.connected_endpoint.device }} - - - {{ csp.connected_endpoint }} - - {% else %} - - Not connected - - {% endif %} + {% include 'dcim/inc/endpoint_connection.html' with paths=csp.paths.all %} {# Actions #} diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html new file mode 100644 index 000000000..07d73a534 --- /dev/null +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -0,0 +1,10 @@ +{% if paths|length > 1 %} + Multiple connections +{% elif paths %} + {% with endpoint=paths.0.destination %} + {{ endpoint.parent }} + {{ endpoint }} + {% endwith %} +{% else %} + Not connected +{% endif %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 706801dd1..ae1363dba 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -76,18 +76,7 @@ {% elif iface.is_wireless %} Wireless interface {% else %} - {% with path_count=iface.get_connections.count %} - {% if path_count > 1 %} - Multiple connections - {% elif path_count %} - {% with endpoint=iface.get_connections.first.destination %} - {{ endpoint.parent }} - {{ endpoint }} - {% endwith %} - {% else %} - Not connected - {% endif %} - {% endwith %} + {% include 'dcim/inc/endpoint_connection.html' with paths=iface.paths.all %} {% endif %} {# Buttons #} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 5800f4b48..39af6828d 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -49,27 +49,20 @@ {# Connection #} - {% if po.connected_endpoint %} - {% with pp=po.connected_endpoint %} - - {{ pp.device }} - - - {{ pp }} - - - {% if pp.allocated_draw %} - {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} - {% elif pp.maximum_draw %} - {{ pp.maximum_draw }}W - {% endif %} - - {% endwith %} - {% else %} - - Not connected + {% with paths=po.paths.all %} + {% include 'dcim/inc/endpoint_connection.html' %} + + {% if paths|length == 1 %} + {% with pp=paths.0.destination %} + {% if pp.allocated_draw %} + {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} + {% elif pp.maximum_draw %} + {{ pp.maximum_draw }}W + {% endif %} + {% endwith %} + {% endif %} - {% endif %} + {% endwith %} {# Actions #} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index b30fc8456..4ec1b786e 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -45,22 +45,7 @@ {# Connection #} - {% if pp.connected_endpoint.device %} - - {{ pp.connected_endpoint.device }} - - - {{ pp.connected_endpoint }} - - {% elif pp.connected_endpoint %} - - {{ pp.connected_endpoint }} - - {% else %} - - Not connected - - {% endif %} + {% include 'dcim/inc/endpoint_connection.html' with paths=pp.paths.all %} {# Actions #} From 105c0fd3d282bee4ab1602fc72190fc764811465 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 12:18:42 -0400 Subject: [PATCH 072/291] Introduce retrace_paths management command --- netbox/dcim/management/__init__.py | 0 netbox/dcim/management/commands/__init__.py | 0 .../dcim/management/commands/retrace_paths.py | 67 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 netbox/dcim/management/__init__.py create mode 100644 netbox/dcim/management/commands/__init__.py create mode 100644 netbox/dcim/management/commands/retrace_paths.py diff --git a/netbox/dcim/management/__init__.py b/netbox/dcim/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/management/commands/__init__.py b/netbox/dcim/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/management/commands/retrace_paths.py b/netbox/dcim/management/commands/retrace_paths.py new file mode 100644 index 000000000..76c29e89c --- /dev/null +++ b/netbox/dcim/management/commands/retrace_paths.py @@ -0,0 +1,67 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand +from django.core.management.color import no_style +from django.db import connection +from django.db.models import Q + +from dcim.models import CablePath, Interface +from dcim.signals import create_cablepaths + +ENDPOINT_MODELS = ( + 'circuits.CircuitTermination', + 'dcim.ConsolePort', + 'dcim.ConsoleServerPort', + 'dcim.Interface', + 'dcim.PowerOutlet', + 'dcim.PowerPort', +) + + +class Command(BaseCommand): + help = "Recalculate natural ordering values for the specified models" + + def add_arguments(self, parser): + parser.add_argument( + 'args', metavar='app_label.ModelName', nargs='*', + help='One or more specific models (each prefixed with its app_label) to retrace', + ) + + def _get_content_types(self, model_names): + q = Q() + for model_name in model_names: + app_label, model = model_name.split('.') + q |= Q(app_label=app_label, model=model) + return ContentType.objects.filter(q) + + def handle(self, *model_names, **options): + # Determine the models for which we're retracing all paths + origin_types = self._get_content_types(model_names or ENDPOINT_MODELS) + self.stdout.write(f"Retracing paths for models: {', '.join([str(ct) for ct in origin_types])}") + + # Delete all existing CablePath instances + self.stdout.write(f"Deleting existing cable paths...") + deleted_count, _ = CablePath.objects.filter(origin_type__in=origin_types).delete() + self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths'))) + + # Reset the SQL sequence. Can do this only if deleting _all_ CablePaths. + if not CablePath.objects.count(): + self.stdout.write(f'Resetting database sequence for CablePath...') + sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath]) + with connection.cursor() as cursor: + for sql in sequence_sql: + cursor.execute(sql) + self.stdout.write(self.style.SUCCESS(' Success.')) + + # Retrace interfaces + for ct in origin_types: + model = ct.model_class() + origins = model.objects.filter(cable__isnull=False) + print(f'Retracing {origins.count()} cabled {model._meta.verbose_name_plural}...') + i = 0 + for i, obj in enumerate(origins, start=1): + create_cablepaths(obj) + if not i % 1000: + self.stdout.write(f' {i}') + self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}')) + + self.stdout.write(self.style.SUCCESS('Finished.')) From 8abc05544cb2fbcbbf919b9546af2400b6123dde Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 13:05:00 -0400 Subject: [PATCH 073/291] CircuitTermination and PowerFeed are path endpoints --- netbox/circuits/models.py | 4 ++-- netbox/dcim/management/commands/retrace_paths.py | 1 + netbox/dcim/models/power.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 408a53c3c..686ab9219 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,7 +4,7 @@ from taggit.managers import TaggableManager from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField -from dcim.models import CableTermination +from dcim.models import CableTermination, PathEndpoint from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.querysets import RestrictedQuerySet @@ -232,7 +232,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): return self._get_termination('Z') -class CircuitTermination(CableTermination): +class CircuitTermination(PathEndpoint, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/dcim/management/commands/retrace_paths.py b/netbox/dcim/management/commands/retrace_paths.py index 76c29e89c..b60537b37 100644 --- a/netbox/dcim/management/commands/retrace_paths.py +++ b/netbox/dcim/management/commands/retrace_paths.py @@ -12,6 +12,7 @@ ENDPOINT_MODELS = ( 'dcim.ConsolePort', 'dcim.ConsoleServerPort', 'dcim.Interface', + 'dcim.PowerFeed', 'dcim.PowerOutlet', 'dcim.PowerPort', ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index f55d077a4..caa22e74a 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -10,7 +10,7 @@ from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator -from .device_components import CableTermination +from .device_components import CableTermination, PathEndpoint __all__ = ( 'PowerFeed', @@ -73,7 +73,7 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): +class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel): """ An electrical circuit delivered from a PowerPanel. """ From cd398b15d83107ba5ee6f296d566f1b9d7a21622 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 13:09:29 -0400 Subject: [PATCH 074/291] retrace_paths should ignore case in model names --- netbox/dcim/management/commands/retrace_paths.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/management/commands/retrace_paths.py b/netbox/dcim/management/commands/retrace_paths.py index b60537b37..a27833d62 100644 --- a/netbox/dcim/management/commands/retrace_paths.py +++ b/netbox/dcim/management/commands/retrace_paths.py @@ -4,7 +4,7 @@ from django.core.management.color import no_style from django.db import connection from django.db.models import Q -from dcim.models import CablePath, Interface +from dcim.models import CablePath from dcim.signals import create_cablepaths ENDPOINT_MODELS = ( @@ -31,7 +31,7 @@ class Command(BaseCommand): q = Q() for model_name in model_names: app_label, model = model_name.split('.') - q |= Q(app_label=app_label, model=model) + q |= Q(app_label__iexact=app_label, model__iexact=model) return ContentType.objects.filter(q) def handle(self, *model_names, **options): From 610420c0205089b424d5073cdecb87c40262e6f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 14:16:43 -0400 Subject: [PATCH 075/291] Drop support for split paths --- netbox/dcim/api/serializers.py | 21 +++--- .../dcim/management/commands/retrace_paths.py | 4 +- netbox/dcim/models/device_components.py | 12 +++- netbox/dcim/signals.py | 13 ++-- netbox/dcim/tests/test_cablepaths.py | 68 ++++++------------- netbox/dcim/utils.py | 19 ++---- netbox/dcim/views.py | 10 +-- netbox/templates/dcim/inc/consoleport.html | 2 +- .../templates/dcim/inc/consoleserverport.html | 2 +- .../dcim/inc/endpoint_connection.html | 6 +- netbox/templates/dcim/inc/interface.html | 2 +- netbox/templates/dcim/inc/poweroutlet.html | 2 +- netbox/templates/dcim/inc/powerport.html | 2 +- 13 files changed, 66 insertions(+), 97 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 725639321..cc8e6df1f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -33,11 +33,9 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) def get_connected_endpoint_type(self, obj): - if hasattr(obj, 'connected_endpoint') and obj.connected_endpoint is not None: - return '{}.{}'.format( - obj.connected_endpoint._meta.app_label, - obj.connected_endpoint._meta.model_name - ) + if obj.path is not None: + destination = obj.path.destination + return f'{destination._meta.app_label}.{destination._meta.model_name}' return None @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -45,14 +43,11 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): """ Return the appropriate serializer for the type of connected object. """ - if getattr(obj, 'connected_endpoint', None) is None: - return None - - serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested') - context = {'request': self.context['request']} - data = serializer(obj.connected_endpoint, context=context).data - - return data + if obj.path is not None: + serializer = get_serializer_for_model(obj.path.destination, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.path.destination, context=context).data + return None # diff --git a/netbox/dcim/management/commands/retrace_paths.py b/netbox/dcim/management/commands/retrace_paths.py index a27833d62..d11a85417 100644 --- a/netbox/dcim/management/commands/retrace_paths.py +++ b/netbox/dcim/management/commands/retrace_paths.py @@ -5,7 +5,7 @@ from django.db import connection from django.db.models import Q from dcim.models import CablePath -from dcim.signals import create_cablepaths +from dcim.signals import create_cablepath ENDPOINT_MODELS = ( 'circuits.CircuitTermination', @@ -60,7 +60,7 @@ class Command(BaseCommand): print(f'Retracing {origins.count()} cabled {model._meta.verbose_name_plural}...') i = 0 for i, obj in enumerate(origins, start=1): - create_cablepaths(obj) + create_cablepath(obj) if not i % 1000: self.stdout.write(f' {i}') self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}')) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 56e8f6fc4..6bf2ac77a 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,7 +1,6 @@ import logging from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -256,7 +255,7 @@ class PathEndpoint(models.Model): """ Any object which may serve as either endpoint of a CablePath. """ - paths = GenericRelation( + _paths = GenericRelation( to='dcim.CablePath', content_type_field='origin_type', object_id_field='origin_id', @@ -266,6 +265,15 @@ class PathEndpoint(models.Model): class Meta: abstract = True + @property + def path(self): + """ + Return the _complete_ CablePath associated with this origin point, if any. + """ + if not hasattr(self, '_path'): + self._path = self._paths.filter(destination_id__isnull=False).first() + return self._path + # # Console ports diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 4e6cadb28..46a2cf1d3 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -6,14 +6,15 @@ from django.db import transaction from django.dispatch import receiver from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis -from .utils import object_to_path_node, trace_paths +from .utils import object_to_path_node, trace_path -def create_cablepaths(node): +def create_cablepath(node): """ Create CablePaths for all paths originating from the specified node. """ - for path, destination in trace_paths(node): + path, destination = trace_path(node) + if path: cp = CablePath(origin=node, path=path, destination=destination) cp.save() @@ -28,7 +29,7 @@ def rebuild_paths(obj): with transaction.atomic(): for cp in cable_paths: cp.delete() - create_cablepaths(cp.origin) + create_cablepath(cp.origin) @receiver(post_save, sender=VirtualChassis) @@ -76,7 +77,7 @@ def update_connected_endpoints(instance, created, **kwargs): if created: for termination in (instance.termination_a, instance.termination_b): if isinstance(termination, PathEndpoint): - create_cablepaths(termination) + create_cablepath(termination) else: rebuild_paths(termination) else: @@ -116,4 +117,4 @@ def nullify_connected_endpoints(instance, **kwargs): origin_type=ContentType.objects.get_for_model(origin), origin_id=origin.pk ).delete() - create_cablepaths(origin) + create_cablepath(origin) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 3d4efae8e..68121e671 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -123,69 +123,43 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_02_interfaces_to_interface_via_pass_through(self): + def test_02_interface_to_interface_via_pass_through(self): """ - [IF1] --C1-- [FP1:1] [RP1] --C3-- [IF3] - [IF2] --C2-- [FP1:2] + [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ - # Create cables 1 and 2 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + # Create cable 1 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[16]) cable1.save() - cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[16], self.rear_ports[4]) + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 2 + cable2 = Cable(termination_a=self.rear_ports[4], termination_b=self.interfaces[1]) cable2.save() self.assertPathExists( origin=self.interfaces[0], - destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0]) + destination=self.interfaces[1], + path=(cable1, self.front_ports[16], self.rear_ports[4], cable2) ) self.assertPathExists( origin=self.interfaces[1], - destination=None, - path=(cable2, self.front_ports[1], self.rear_ports[0]) + destination=self.interfaces[0], + path=(cable2, self.rear_ports[4], self.front_ports[16], cable1) ) self.assertEqual(CablePath.objects.count(), 2) - # Create cable 3 - cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.interfaces[2]) - cable3.save() - self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[2], - path=(cable1, self.front_ports[0], self.rear_ports[0], cable3) - ) - self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[2], - path=(cable2, self.front_ports[1], self.rear_ports[0], cable3) - ) - self.assertPathExists( - origin=self.interfaces[2], - destination=self.interfaces[0], - path=(cable3, self.rear_ports[0], self.front_ports[0], cable1) - ) - self.assertPathExists( - origin=self.interfaces[2], - destination=self.interfaces[1], - path=(cable3, self.rear_ports[0], self.front_ports[1], cable2) - ) - self.assertEqual(CablePath.objects.count(), 6) # Four complete + two partial paths - - # Delete cable 3 - cable3.delete() + # Delete cable 2 + cable2.delete() self.assertPathExists( origin=self.interfaces[0], destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0]) + path=(cable1, self.front_ports[16], self.rear_ports[4]) ) - self.assertPathExists( - origin=self.interfaces[1], - destination=None, - path=(cable2, self.front_ports[1], self.rear_ports[0]) - ) - - # Check for two partial paths from IF1 and IF2 - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.count(), 1) def test_03_interfaces_to_interfaces_via_pass_through(self): """ diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 75029cacc..59ca59bfc 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType +from .exceptions import CableTraceSplit from .models import FrontPort, RearPort @@ -17,13 +18,13 @@ def path_node_to_object(repr): return model_class.objects.get(pk=int(object_id)) -def trace_paths(node): +def trace_path(node): destination = None path = [] position_stack = [] if node.cable is None: - return [] + return [], None while node.cable is not None: @@ -50,20 +51,12 @@ def trace_paths(node): node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position) path.append(object_to_path_node(node)) else: - # No position indicated, so we have to trace _all_ peer FrontPorts - paths = [] - for frontport in FrontPort.objects.filter(rear_port=peer_termination): - branches = trace_paths(frontport) - if branches: - for branch, destination in branches: - paths.append(([*path, object_to_path_node(frontport), *branch], destination)) - else: - paths.append(([*path, object_to_path_node(frontport)], None)) - return paths + # No position indicated: path has split (probably invalid?) + raise CableTraceSplit(peer_termination) # Anything else marks the end of the path else: destination = peer_termination break - return [(path, destination)] + return path, destination diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 58be5d213..96e6615e8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1018,7 +1018,7 @@ class DeviceView(ObjectView): # Console ports consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), 'cable', ) @@ -1026,25 +1026,25 @@ class DeviceView(ObjectView): consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( device=device ).prefetch_related( - Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), 'cable', ) # Power ports powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), 'cable', ) # Power outlets poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), 'cable', 'power_port', ) # Interfaces interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related( - Prefetch('paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), + Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), 'lag', 'cable', 'tags', diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index dc0ff384c..912404be3 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -36,7 +36,7 @@ {# Connection #} - {% include 'dcim/inc/endpoint_connection.html' with paths=cp.paths.all %} + {% include 'dcim/inc/endpoint_connection.html' with path=cp.path %} {# Actions #} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 0af64b4c1..b7a5c6b56 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -38,7 +38,7 @@ {# Connection #} - {% include 'dcim/inc/endpoint_connection.html' with paths=csp.paths.all %} + {% include 'dcim/inc/endpoint_connection.html' with path=csp.path %} {# Actions #} diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index 07d73a534..1c25a0e28 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,7 +1,5 @@ -{% if paths|length > 1 %} - Multiple connections -{% elif paths %} - {% with endpoint=paths.0.destination %} +{% if path %} + {% with endpoint=path.destination %} {{ endpoint.parent }} {{ endpoint }} {% endwith %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index ae1363dba..159551192 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -76,7 +76,7 @@ {% elif iface.is_wireless %} Wireless interface {% else %} - {% include 'dcim/inc/endpoint_connection.html' with paths=iface.paths.all %} + {% include 'dcim/inc/endpoint_connection.html' with path=iface.path %} {% endif %} {# Buttons #} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 39af6828d..b3e003e99 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -49,7 +49,7 @@ {# Connection #} - {% with paths=po.paths.all %} + {% with path=po.path %} {% include 'dcim/inc/endpoint_connection.html' %} {% if paths|length == 1 %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 4ec1b786e..c65b685d7 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -45,7 +45,7 @@ {# Connection #} - {% include 'dcim/inc/endpoint_connection.html' with paths=pp.paths.all %} + {% include 'dcim/inc/endpoint_connection.html' with path=pp.path %} {# Actions #} From c974c5687c4ebe90f6f15baa32692f6620bf073a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 16:42:57 -0400 Subject: [PATCH 076/291] Capture path end-to-end status in CablePath --- netbox/dcim/api/serializers.py | 9 ++++++++- netbox/dcim/migrations/0120_cablepath.py | 3 +-- netbox/dcim/models/devices.py | 5 +++++ netbox/dcim/signals.py | 17 +++++++++++------ netbox/dcim/utils.py | 8 ++++++-- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index cc8e6df1f..8078f8819 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -30,7 +30,7 @@ from .nested_serializers import * class ConnectedEndpointSerializer(ValidatedModelSerializer): connected_endpoint_type = serializers.SerializerMethodField(read_only=True) connected_endpoint = serializers.SerializerMethodField(read_only=True) - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + connection_status = serializers.SerializerMethodField(read_only=True) def get_connected_endpoint_type(self, obj): if obj.path is not None: @@ -49,6 +49,13 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): return serializer(obj.path.destination, context=context).data return None + # TODO: Tweak the representation for this field + @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + def get_connection_status(self, obj): + if obj.path is not None: + return obj.path.is_connected + return None + # # Regions/sites diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py index 4e36c31d0..2cb8376b7 100644 --- a/netbox/dcim/migrations/0120_cablepath.py +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -1,5 +1,3 @@ -# Generated by Django 3.1 on 2020-09-30 18:09 - import dcim.fields from django.db import migrations, models import django.db.models.deletion @@ -22,6 +20,7 @@ class Migration(migrations.Migration): ('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)), ('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), ('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ('is_connected', models.BooleanField(default=False)), ], ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 162dcb831..3b13b1f73 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -992,6 +992,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel): instance._orig_termination_b_type_id = instance.termination_b_type_id instance._orig_termination_b_id = instance.termination_b_id + instance._orig_status = instance.status + return instance def __str__(self): @@ -1188,6 +1190,9 @@ class CablePath(models.Model): fk_field='destination_id' ) path = PathField() + is_connected = models.BooleanField( + default=False + ) objects = CablePathManager() diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 46a2cf1d3..0c5da6160 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -5,6 +5,7 @@ from django.db.models.signals import post_save, pre_delete from django.db import transaction from django.dispatch import receiver +from .choices import CableStatusChoices from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis from .utils import object_to_path_node, trace_path @@ -13,9 +14,9 @@ def create_cablepath(node): """ Create CablePaths for all paths originating from the specified node. """ - path, destination = trace_path(node) + path, destination, is_connected = trace_path(node) if path: - cp = CablePath(origin=node, path=path, destination=destination) + cp = CablePath(origin=node, path=path, destination=destination, is_connected=is_connected) cp.save() @@ -80,10 +81,14 @@ def update_connected_endpoints(instance, created, **kwargs): create_cablepath(termination) else: rebuild_paths(termination) - else: - # We currently don't support modifying either termination of an existing Cable. This - # may change in the future. - pass + elif instance.status != instance._orig_status: + # We currently don't support modifying either termination of an existing Cable. (This + # may change in the future.) However, we do need to capture status changes and update + # any CablePaths accordingly. + if instance.status != CableStatusChoices.STATUS_CONNECTED: + CablePath.objects.filter(path__contains=object_to_path_node(instance)).update(is_connected=False) + else: + rebuild_paths(instance) @receiver(pre_delete, sender=Cable) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 59ca59bfc..f97a1e8f0 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType +from .choices import CableStatusChoices from .exceptions import CableTraceSplit from .models import FrontPort, RearPort @@ -22,11 +23,14 @@ def trace_path(node): destination = None path = [] position_stack = [] + is_connected = True if node.cable is None: - return [], None + return [], None, False while node.cable is not None: + if node.cable.status != CableStatusChoices.STATUS_CONNECTED: + is_connected = False # Follow the cable to its far-end termination path.append(object_to_path_node(node.cable)) @@ -59,4 +63,4 @@ def trace_path(node): destination = peer_termination break - return path, destination + return path, destination, is_connected From 0d07b0346b05064b30c472e75bf152f6f7395699 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 16:53:13 -0400 Subject: [PATCH 077/291] Add test for connecting cables out of order --- netbox/dcim/tests/test_cablepaths.py | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 68121e671..529935eb3 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -376,3 +376,43 @@ class CablePathTestCase(TestCase): # Check for four partial paths; one from each interface self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + + def test_06_interface_to_interface_via_existing_cable(self): + """ + [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] + """ + # Create cable 2 + cable2 = Cable(termination_a=self.rear_ports[4], termination_b=self.rear_ports[5]) + cable2.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[16]) + cable1.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=None, + path=(cable1, self.front_ports[16], self.rear_ports[4], cable2, self.rear_ports[5], self.front_ports[17]) + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 3 + cable3 = Cable(termination_a=self.front_ports[17], termination_b=self.interfaces[1]) + cable3.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[1], + path=( + cable1, self.front_ports[16], self.rear_ports[4], cable2, self.rear_ports[5], self.front_ports[17], + cable3, + ) + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[0], + path=( + cable3, self.front_ports[17], self.rear_ports[5], cable2, self.rear_ports[4], self.front_ports[16], + cable1, + ) + ) + self.assertEqual(CablePath.objects.count(), 2) From 3b0a75edf85d4b161f5a174a7ad82109cd8979c5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Oct 2020 17:25:44 -0400 Subject: [PATCH 078/291] Add test for updated paths on cable status change --- netbox/dcim/models/devices.py | 5 +- netbox/dcim/signals.py | 2 +- netbox/dcim/tests/test_cablepaths.py | 128 +++++++++++++++++++++------ netbox/dcim/utils.py | 3 + 4 files changed, 109 insertions(+), 29 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 3b13b1f73..0cb1ea970 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -980,6 +980,9 @@ class Cable(ChangeLoggedModel, CustomFieldModel): # A copy of the PK to be used by __str__ in case the object is deleted self._pk = self.pk + # Cache the original status so we can check later if it's been changed + self._orig_status = self.status + @classmethod def from_db(cls, db, field_names, values): """ @@ -992,8 +995,6 @@ class Cable(ChangeLoggedModel, CustomFieldModel): instance._orig_termination_b_type_id = instance.termination_b_type_id instance._orig_termination_b_id = instance.termination_b_id - instance._orig_status = instance.status - return instance def __str__(self): diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 0c5da6160..9b0493e34 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -86,7 +86,7 @@ def update_connected_endpoints(instance, created, **kwargs): # may change in the future.) However, we do need to capture status changes and update # any CablePaths accordingly. if instance.status != CableStatusChoices.STATUS_CONNECTED: - CablePath.objects.filter(path__contains=object_to_path_node(instance)).update(is_connected=False) + CablePath.objects.filter(path__contains=[object_to_path_node(instance)]).update(is_connected=False) else: rebuild_paths(instance) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 529935eb3..362b3804b 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import * +from dcim.choices import CableStatusChoices from dcim.models import * from dcim.utils import objects_to_path @@ -70,13 +71,14 @@ class CablePathTestCase(TestCase): ] CircuitTermination.objects.bulk_create(cls.circuit_terminations) - def assertPathExists(self, origin, destination, path=None, msg=None): + def assertPathExists(self, origin, destination, path=None, is_connected=None, msg=None): """ Assert that a CablePath from origin to destination with a specific intermediate path exists. :param origin: Originating endpoint :param destination: Terminating endpoint, or None :param path: Sequence of objects comprising the intermediate path (optional) + :param is_connected: Boolean indicating whether the end-to-end path is complete and active (optional) :param msg: Custom failure message (optional) """ kwargs = { @@ -91,6 +93,8 @@ class CablePathTestCase(TestCase): kwargs['destination_id__isnull'] = True if path is not None: kwargs['path'] = objects_to_path(*path) + if is_connected is not None: + kwargs['is_connected'] = is_connected if msg is None: if destination is not None: msg = f"Missing path from {origin} to {destination}" @@ -108,12 +112,14 @@ class CablePathTestCase(TestCase): self.assertPathExists( origin=self.interfaces[0], destination=self.interfaces[1], - path=(cable1,) + path=(cable1,), + is_connected=True ) self.assertPathExists( origin=self.interfaces[1], destination=self.interfaces[0], - path=(cable1,) + path=(cable1,), + is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -133,7 +139,8 @@ class CablePathTestCase(TestCase): self.assertPathExists( origin=self.interfaces[0], destination=None, - path=(cable1, self.front_ports[16], self.rear_ports[4]) + path=(cable1, self.front_ports[16], self.rear_ports[4]), + is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -143,12 +150,14 @@ class CablePathTestCase(TestCase): self.assertPathExists( origin=self.interfaces[0], destination=self.interfaces[1], - path=(cable1, self.front_ports[16], self.rear_ports[4], cable2) + path=(cable1, self.front_ports[16], self.rear_ports[4], cable2), + is_connected=True ) self.assertPathExists( origin=self.interfaces[1], destination=self.interfaces[0], - path=(cable2, self.rear_ports[4], self.front_ports[16], cable1) + path=(cable2, self.rear_ports[4], self.front_ports[16], cable1), + is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -157,7 +166,8 @@ class CablePathTestCase(TestCase): self.assertPathExists( origin=self.interfaces[0], destination=None, - path=(cable1, self.front_ports[16], self.rear_ports[4]) + path=(cable1, self.front_ports[16], self.rear_ports[4]), + is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -174,12 +184,14 @@ class CablePathTestCase(TestCase): self.assertPathExists( origin=self.interfaces[0], destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0]) + path=(cable1, self.front_ports[0], self.rear_ports[0]), + is_connected=False ) self.assertPathExists( origin=self.interfaces[1], destination=None, - path=(cable2, self.front_ports[1], self.rear_ports[0]) + path=(cable2, self.front_ports[1], self.rear_ports[0]), + is_connected=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -189,12 +201,14 @@ class CablePathTestCase(TestCase): self.assertPathExists( origin=self.interfaces[0], destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4]) + path=(cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4]), + is_connected=False ) self.assertPathExists( origin=self.interfaces[1], destination=None, - path=(cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5]) + path=(cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5]), + is_connected=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -209,7 +223,8 @@ class CablePathTestCase(TestCase): path=( cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4], cable4, - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[1], @@ -217,7 +232,8 @@ class CablePathTestCase(TestCase): path=( cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5], cable5, - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[2], @@ -225,7 +241,8 @@ class CablePathTestCase(TestCase): path=( cable4, self.front_ports[4], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[0], cable1 - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[3], @@ -233,7 +250,8 @@ class CablePathTestCase(TestCase): path=( cable5, self.front_ports[5], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[1], cable2 - ) + ), + is_connected=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -277,7 +295,8 @@ class CablePathTestCase(TestCase): cable1, self.front_ports[0], self.rear_ports[0], cable3, self.front_ports[4], self.rear_ports[1], cable4, self.rear_ports[2], self.front_ports[8], cable5, self.rear_ports[3], self.front_ports[12], cable6 - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[1], @@ -286,7 +305,8 @@ class CablePathTestCase(TestCase): cable2, self.front_ports[1], self.rear_ports[0], cable3, self.front_ports[4], self.rear_ports[1], cable4, self.rear_ports[2], self.front_ports[8], cable5, self.rear_ports[3], self.front_ports[13], cable7 - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[2], @@ -295,7 +315,8 @@ class CablePathTestCase(TestCase): cable6, self.front_ports[12], self.rear_ports[3], cable5, self.front_ports[8], self.rear_ports[2], cable4, self.rear_ports[1], self.front_ports[4], cable3, self.rear_ports[0], self.front_ports[0], cable1 - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[3], @@ -304,7 +325,8 @@ class CablePathTestCase(TestCase): cable7, self.front_ports[13], self.rear_ports[3], cable5, self.front_ports[8], self.rear_ports[2], cable4, self.rear_ports[1], self.front_ports[4], cable3, self.rear_ports[0], self.front_ports[1], cable2 - ) + ), + is_connected=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -342,7 +364,8 @@ class CablePathTestCase(TestCase): path=( cable1, self.front_ports[0], self.rear_ports[0], cable3, self.front_ports[16], self.rear_ports[4], cable4, self.rear_ports[1], self.front_ports[4], cable5 - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[1], @@ -350,7 +373,8 @@ class CablePathTestCase(TestCase): path=( cable2, self.front_ports[1], self.rear_ports[0], cable3, self.front_ports[16], self.rear_ports[4], cable4, self.rear_ports[1], self.front_ports[5], cable6 - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[2], @@ -358,7 +382,8 @@ class CablePathTestCase(TestCase): path=( cable5, self.front_ports[4], self.rear_ports[1], cable4, self.rear_ports[4], self.front_ports[16], cable3, self.rear_ports[0], self.front_ports[0], cable1 - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[3], @@ -366,7 +391,8 @@ class CablePathTestCase(TestCase): path=( cable6, self.front_ports[5], self.rear_ports[1], cable4, self.rear_ports[4], self.front_ports[16], cable3, self.rear_ports[0], self.front_ports[1], cable2 - ) + ), + is_connected=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -392,7 +418,8 @@ class CablePathTestCase(TestCase): self.assertPathExists( origin=self.interfaces[0], destination=None, - path=(cable1, self.front_ports[16], self.rear_ports[4], cable2, self.rear_ports[5], self.front_ports[17]) + path=(cable1, self.front_ports[16], self.rear_ports[4], cable2, self.rear_ports[5], self.front_ports[17]), + is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -405,7 +432,8 @@ class CablePathTestCase(TestCase): path=( cable1, self.front_ports[16], self.rear_ports[4], cable2, self.rear_ports[5], self.front_ports[17], cable3, - ) + ), + is_connected=True ) self.assertPathExists( origin=self.interfaces[1], @@ -413,6 +441,54 @@ class CablePathTestCase(TestCase): path=( cable3, self.front_ports[17], self.rear_ports[5], cable2, self.rear_ports[4], self.front_ports[16], cable1, - ) + ), + is_connected=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + def test_07_change_cable_status(self): + """ + [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] + """ + # Create cables 1 and 2 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[16]) + cable1.save() + cable2 = Cable(termination_a=self.rear_ports[4], termination_b=self.interfaces[1]) + cable2.save() + self.assertEqual(CablePath.objects.filter(is_connected=True).count(), 2) + self.assertEqual(CablePath.objects.count(), 2) + + # Change cable 2's status to "planned" + cable2.status = CableStatusChoices.STATUS_PLANNED + cable2.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[1], + path=(cable1, self.front_ports[16], self.rear_ports[4], cable2), + is_connected=False + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[0], + path=(cable2, self.rear_ports[4], self.front_ports[16], cable1), + is_connected=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Change cable 2's status to "connected" + cable2 = Cable.objects.get(pk=cable2.pk) + cable2.status = CableStatusChoices.STATUS_CONNECTED + cable2.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[1], + path=(cable1, self.front_ports[16], self.rear_ports[4], cable2), + is_connected=True + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[0], + path=(cable2, self.rear_ports[4], self.front_ports[16], cable1), + is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index f97a1e8f0..16d0753ba 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -63,4 +63,7 @@ def trace_path(node): destination = peer_termination break + if destination is None: + is_connected = False + return path, destination, is_connected From d50a0d94be887effd3f60faaa1b703759afca778 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 09:54:12 -0400 Subject: [PATCH 079/291] Add tests for multiple pass-through breakouts --- netbox/dcim/tests/test_cablepaths.py | 80 ++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 362b3804b..a851a010b 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -337,7 +337,81 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) - def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): + def test_05_interfaces_to_interfaces_via_multiple_pass_throughs(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3] + [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] + """ + # Create cables 1-3, 6-8 + cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1.save() + cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + cable2.save() + cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.rear_ports[1]) + cable3.save() + cable6 = Cable(termination_a=self.rear_ports[2], termination_b=self.rear_ports[3]) + cable6.save() + cable7 = Cable(termination_a=self.interfaces[2], termination_b=self.front_ports[12]) + cable7.save() + cable8 = Cable(termination_a=self.interfaces[3], termination_b=self.front_ports[13]) + cable8.save() + self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface + + # Create cables 4 and 5 + cable4 = Cable(termination_a=self.front_ports[4], termination_b=self.front_ports[8]) + cable4.save() + cable5 = Cable(termination_a=self.front_ports[5], termination_b=self.front_ports[9]) + cable5.save() + self.assertPathExists( + origin=self.interfaces[0], + destination=self.interfaces[2], + path=( + cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4], + cable4, self.front_ports[8], self.rear_ports[2], cable6, self.rear_ports[3], self.front_ports[12], + cable7 + ), + is_connected=True + ) + self.assertPathExists( + origin=self.interfaces[1], + destination=self.interfaces[3], + path=( + cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5], + cable5, self.front_ports[9], self.rear_ports[2], cable6, self.rear_ports[3], self.front_ports[13], + cable8 + ), + is_connected=True + ) + self.assertPathExists( + origin=self.interfaces[2], + destination=self.interfaces[0], + path=( + cable7, self.front_ports[12], self.rear_ports[3], cable6, self.rear_ports[2], self.front_ports[8], + cable4, self.front_ports[4], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[0], + cable1 + ), + is_connected=True + ) + self.assertPathExists( + origin=self.interfaces[3], + destination=self.interfaces[1], + path=( + cable8, self.front_ports[13], self.rear_ports[3], cable6, self.rear_ports[2], self.front_ports[9], + cable5, self.front_ports[5], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[1], + cable2 + ), + is_connected=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 5 + cable5.delete() + + # Check for two complete paths (IF1 <--> IF2) and two partial (IF3 <--> IF4) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2) + self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 2) + + def test_06_interfaces_to_interfaces_via_patched_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP5] [RP5] --C4-- [RP2] [FP2:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] @@ -403,7 +477,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) - def test_06_interface_to_interface_via_existing_cable(self): + def test_07_interface_to_interface_via_existing_cable(self): """ [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] """ @@ -446,7 +520,7 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) - def test_07_change_cable_status(self): + def test_08_change_cable_status(self): """ [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ From 9d10c57dc9a892ad8c6bebfb6ff881d58720ed43 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 09:55:28 -0400 Subject: [PATCH 080/291] Remove legacy CablePathTestCase --- netbox/dcim/tests/test_models.py | 625 ------------------------------- 1 file changed, 625 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index c55d099c9..83438a609 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -561,628 +561,3 @@ class CableTestCase(TestCase): cable = Cable(termination_a=self.interface2, termination_b=wireless_interface) with self.assertRaises(ValidationError): cable.clean() - - -class CablePathTestCase(TestCase): - - @classmethod - def setUpTestData(cls): - - site = Site.objects.create(name='Site 1', slug='site-1') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' - ) - devicerole = DeviceRole.objects.create( - name='Device Role 1', slug='device-role-1', color='ff0000' - ) - provider = Provider.objects.create(name='Provider 1', slug='provider-1') - circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - circuit = Circuit.objects.create(provider=provider, type=circuittype, cid='1') - CircuitTermination.objects.bulk_create(( - CircuitTermination(circuit=circuit, site=site, term_side='A', port_speed=1000), - CircuitTermination(circuit=circuit, site=site, term_side='Z', port_speed=1000), - )) - - # Create four network devices with four interfaces each - devices = ( - Device(device_type=devicetype, device_role=devicerole, name='Device 1', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Device 2', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Device 3', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Device 4', site=site), - ) - Device.objects.bulk_create(devices) - for device in devices: - Interface.objects.bulk_create(( - Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - )) - - # Create four patch panels, each with one rear port and four front ports - patch_panels = ( - Device(device_type=devicetype, device_role=devicerole, name='Panel 1', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site), - Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site), - ) - Device.objects.bulk_create(patch_panels) - - # Create patch panels with 4 positions - for patch_panel in patch_panels[:4]: - rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C) - FrontPort.objects.bulk_create(( - FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C), - FrontPort(device=patch_panel, name='Front Port 2', rear_port=rearport, rear_port_position=2, type=PortTypeChoices.TYPE_8P8C), - FrontPort(device=patch_panel, name='Front Port 3', rear_port=rearport, rear_port_position=3, type=PortTypeChoices.TYPE_8P8C), - FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C), - )) - - # Create 1-on-1 patch panels - for patch_panel in patch_panels[4:]: - rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C) - FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C) - - def test_direct_connection(self): - """ - Test a direct connection between two interfaces. - - [Device 1] ----- [Device 2] - Iface1 Iface1 - """ - # Create cable - cable = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable.full_clean() - cable.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete cable - cable.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - - def test_connection_via_single_rear_port(self): - """ - Test a connection which passes through a rear port with exactly one front port. - - 1 2 - [Device 1] ----- [Panel 5] ----- [Device 2] - Iface1 FP1 RP1 Iface1 - """ - # Create cables (FP first, RP second) - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - self.assertEqual(cable2.termination_a.positions, 1) # Sanity check - cable2.full_clean() - cable2.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete cable 1 - cable1.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - - def test_connections_via_nested_single_position_rearport(self): - """ - Test a connection which passes through a single front/rear port pair between two multi-position rear ports. - - Test two connections via patched rear ports: - Device 1 <---> Device 2 - Device 3 <---> Device 4 - - 1 2 - [Device 1] -----------+ +----------- [Device 2] - Iface1 | | Iface1 - FP1 | 3 4 | FP1 - [Panel 1] ----- [Panel 5] ----- [Panel 2] - FP2 | RP1 RP1 FP1 RP1 | FP2 - Iface1 | | Iface1 - [Device 3] -----------+ +----------- [Device 4] - 5 6 - """ - # Create cables (Panel 5 RP first, FP second) - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), - termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable2.full_clean() - cable2.save() - cable3 = Cable( - termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1') - ) - cable3.full_clean() - cable3.save() - cable4 = Cable( - termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'), - termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') - ) - cable4.full_clean() - cable4.save() - cable5 = Cable( - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'), - termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1') - ) - cable5.full_clean() - cable5.save() - cable6 = Cable( - termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), - termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1') - ) - cable6.full_clean() - cable6.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') - endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) - self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - self.assertTrue(endpoint_c.connection_status) - self.assertTrue(endpoint_d.connection_status) - - # Delete cable 3 - cable3.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - endpoint_c.refresh_from_db() - endpoint_d.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_c.connected_endpoint) - self.assertIsNone(endpoint_d.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - self.assertIsNone(endpoint_c.connection_status) - self.assertIsNone(endpoint_d.connection_status) - - def test_connections_via_patch(self): - """ - Test two connections via patched rear ports: - Device 1 <---> Device 2 - Device 3 <---> Device 4 - - 1 2 - [Device 1] -----------+ +----------- [Device 2] - Iface1 | | Iface1 - FP1 | 3 | FP1 - [Panel 1] ----- [Panel 2] - FP2 | RP1 RP1 | FP2 - Iface1 | | Iface1 - [Device 3] -----------+ +----------- [Device 4] - 4 5 - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') - ) - cable2.full_clean() - cable2.save() - - cable3 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') - ) - cable3.full_clean() - cable3.save() - - cable4 = Cable( - termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') - ) - cable4.full_clean() - cable4.save() - cable5 = Cable( - termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2') - ) - cable5.full_clean() - cable5.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') - endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) - self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - self.assertTrue(endpoint_c.connection_status) - self.assertTrue(endpoint_d.connection_status) - - # Delete cable 3 - cable3.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - endpoint_c.refresh_from_db() - endpoint_d.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_c.connected_endpoint) - self.assertIsNone(endpoint_d.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - self.assertIsNone(endpoint_c.connection_status) - self.assertIsNone(endpoint_d.connection_status) - - def test_connections_via_multiple_patches(self): - """ - Test two connections via patched rear ports: - Device 1 <---> Device 2 - Device 3 <---> Device 4 - - 1 2 3 - [Device 1] -----------+ +---------------+ +----------- [Device 2] - Iface1 | | | | Iface1 - FP1 | 4 | FP1 FP1 | 5 | FP1 - [Panel 1] ----- [Panel 2] [Panel 3] ----- [Panel 4] - FP2 | RP1 RP1 | FP2 FP2 | RP1 RP1 | FP2 - Iface1 | | | | Iface1 - [Device 3] -----------+ +---------------+ +----------- [Device 4] - 6 7 8 - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), - termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1') - ) - cable2.full_clean() - cable2.save() - cable3 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable3.full_clean() - cable3.save() - - cable4 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') - ) - cable4.full_clean() - cable4.save() - cable5 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') - ) - cable5.full_clean() - cable5.save() - - cable6 = Cable( - termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') - ) - cable6.full_clean() - cable6.save() - cable7 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), - termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2') - ) - cable7.full_clean() - cable7.save() - cable8 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), - termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') - ) - cable8.full_clean() - cable8.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') - endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) - self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - self.assertTrue(endpoint_c.connection_status) - self.assertTrue(endpoint_d.connection_status) - - # Delete cables 4 and 5 - cable4.delete() - cable5.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - endpoint_c.refresh_from_db() - endpoint_d.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_c.connected_endpoint) - self.assertIsNone(endpoint_d.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - self.assertIsNone(endpoint_c.connection_status) - self.assertIsNone(endpoint_d.connection_status) - - def test_connections_via_nested_rear_ports(self): - """ - Test two connections via nested rear ports: - Device 1 <---> Device 2 - Device 3 <---> Device 4 - - 1 2 - [Device 1] -----------+ +----------- [Device 2] - Iface1 | | Iface1 - FP1 | 3 4 5 | FP1 - [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] - FP2 | RP1 FP1 RP1 RP1 FP1 RP1 | FP2 - Iface1 | | Iface1 - [Device 3] -----------+ +----------- [Device 4] - 6 7 - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable2.full_clean() - cable2.save() - - cable3 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') - ) - cable3.full_clean() - cable3.save() - cable4 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1') - ) - cable4.full_clean() - cable4.save() - cable5 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') - ) - cable5.full_clean() - cable5.save() - - cable6 = Cable( - termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') - ) - cable6.full_clean() - cable6.save() - cable7 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), - termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') - ) - cable7.full_clean() - cable7.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') - endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) - self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - self.assertTrue(endpoint_c.connection_status) - self.assertTrue(endpoint_d.connection_status) - - # Delete cable 4 - cable4.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - endpoint_c.refresh_from_db() - endpoint_d.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_c.connected_endpoint) - self.assertIsNone(endpoint_d.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - self.assertIsNone(endpoint_c.connection_status) - self.assertIsNone(endpoint_d.connection_status) - - def test_connection_via_circuit(self): - """ - 1 2 - [Device 1] ----- [Circuit] ----- [Device 2] - Iface1 A Z Iface1 - - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=CircuitTermination.objects.get(term_side='A') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_a=CircuitTermination.objects.get(term_side='Z'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable2.full_clean() - cable2.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete circuit - circuit = Circuit.objects.first().delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - - def test_connection_via_patched_circuit(self): - """ - 1 2 3 4 - [Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2] - Iface1 FP1 RP1 A Z RP1 FP1 Iface1 - - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), - termination_b=CircuitTermination.objects.get(term_side='A') - ) - cable2.full_clean() - cable2.save() - cable3 = Cable( - termination_a=CircuitTermination.objects.get(term_side='Z'), - termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1') - ) - cable3.full_clean() - cable3.save() - cable4 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable4.full_clean() - cable4.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete circuit - circuit = Circuit.objects.first().delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) From 9f242216e6e3ea770a68f904e378c00506cb6398 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 10:14:52 -0400 Subject: [PATCH 081/291] Rename test elements to be more readable --- netbox/dcim/tests/test_cablepaths.py | 375 +++++++++++++++------------ 1 file changed, 202 insertions(+), 173 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index a851a010b..53ce82403 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -19,44 +19,73 @@ class CablePathTestCase(TestCase): device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') device = Device.objects.create(site=site, device_type=device_type, device_role=device_role, name='Test Device') - # Create 16 interfaces for testing - cls.interfaces = [ - Interface(device=device, name=f'Interface {i}') - for i in range(1, 17) - ] - Interface.objects.bulk_create(cls.interfaces) + # Create 4 interfaces for testing + cls.interface1 = Interface(device=device, name=f'Interface 1') + cls.interface2 = Interface(device=device, name=f'Interface 2') + cls.interface3 = Interface(device=device, name=f'Interface 3') + cls.interface4 = Interface(device=device, name=f'Interface 4') + Interface.objects.bulk_create([ + cls.interface1, + cls.interface2, + cls.interface3, + cls.interface4 + ]) - # Create four RearPorts with four FrontPorts each, and two with only one position - cls.rear_ports = [ - RearPort(device=device, name=f'RP1', positions=4), - RearPort(device=device, name=f'RP2', positions=4), - RearPort(device=device, name=f'RP3', positions=4), - RearPort(device=device, name=f'RP4', positions=4), - RearPort(device=device, name=f'RP5', positions=1), - RearPort(device=device, name=f'RP6', positions=1), - ] - RearPort.objects.bulk_create(cls.rear_ports) - cls.front_ports = [ - FrontPort(device=device, name=f'FP1:1', rear_port=cls.rear_ports[0], rear_port_position=1), - FrontPort(device=device, name=f'FP1:2', rear_port=cls.rear_ports[0], rear_port_position=2), - FrontPort(device=device, name=f'FP1:3', rear_port=cls.rear_ports[0], rear_port_position=3), - FrontPort(device=device, name=f'FP1:4', rear_port=cls.rear_ports[0], rear_port_position=4), - FrontPort(device=device, name=f'FP2:1', rear_port=cls.rear_ports[1], rear_port_position=1), - FrontPort(device=device, name=f'FP2:2', rear_port=cls.rear_ports[1], rear_port_position=2), - FrontPort(device=device, name=f'FP2:3', rear_port=cls.rear_ports[1], rear_port_position=3), - FrontPort(device=device, name=f'FP2:4', rear_port=cls.rear_ports[1], rear_port_position=4), - FrontPort(device=device, name=f'FP3:1', rear_port=cls.rear_ports[2], rear_port_position=1), - FrontPort(device=device, name=f'FP3:2', rear_port=cls.rear_ports[2], rear_port_position=2), - FrontPort(device=device, name=f'FP3:3', rear_port=cls.rear_ports[2], rear_port_position=3), - FrontPort(device=device, name=f'FP3:4', rear_port=cls.rear_ports[2], rear_port_position=4), - FrontPort(device=device, name=f'FP4:1', rear_port=cls.rear_ports[3], rear_port_position=1), - FrontPort(device=device, name=f'FP4:2', rear_port=cls.rear_ports[3], rear_port_position=2), - FrontPort(device=device, name=f'FP4:3', rear_port=cls.rear_ports[3], rear_port_position=3), - FrontPort(device=device, name=f'FP4:4', rear_port=cls.rear_ports[3], rear_port_position=4), - FrontPort(device=device, name=f'FP5', rear_port=cls.rear_ports[4], rear_port_position=1), - FrontPort(device=device, name=f'FP6', rear_port=cls.rear_ports[5], rear_port_position=1), - ] - FrontPort.objects.bulk_create(cls.front_ports) + # Create four RearPorts with four positions each, and two with only one position + cls.rear_port1 = RearPort(device=device, name=f'RP1', positions=4) + cls.rear_port2 = RearPort(device=device, name=f'RP2', positions=4) + cls.rear_port3 = RearPort(device=device, name=f'RP3', positions=4) + cls.rear_port4 = RearPort(device=device, name=f'RP4', positions=4) + cls.rear_port5 = RearPort(device=device, name=f'RP5', positions=1) + cls.rear_port6 = RearPort(device=device, name=f'RP6', positions=1) + RearPort.objects.bulk_create([ + cls.rear_port1, + cls.rear_port2, + cls.rear_port3, + cls.rear_port4, + cls.rear_port5, + cls.rear_port6 + ]) + + # Create FrontPorts to match RearPorts (4x4 + 2x1) + cls.front_port1_1 = FrontPort(device=device, name=f'FP1:1', rear_port=cls.rear_port1, rear_port_position=1) + cls.front_port1_2 = FrontPort(device=device, name=f'FP1:2', rear_port=cls.rear_port1, rear_port_position=2) + cls.front_port1_3 = FrontPort(device=device, name=f'FP1:3', rear_port=cls.rear_port1, rear_port_position=3) + cls.front_port1_4 = FrontPort(device=device, name=f'FP1:4', rear_port=cls.rear_port1, rear_port_position=4) + cls.front_port2_1 = FrontPort(device=device, name=f'FP2:1', rear_port=cls.rear_port2, rear_port_position=1) + cls.front_port2_2 = FrontPort(device=device, name=f'FP2:2', rear_port=cls.rear_port2, rear_port_position=2) + cls.front_port2_3 = FrontPort(device=device, name=f'FP2:3', rear_port=cls.rear_port2, rear_port_position=3) + cls.front_port2_4 = FrontPort(device=device, name=f'FP2:4', rear_port=cls.rear_port2, rear_port_position=4) + cls.front_port3_1 = FrontPort(device=device, name=f'FP3:1', rear_port=cls.rear_port3, rear_port_position=1) + cls.front_port3_2 = FrontPort(device=device, name=f'FP3:2', rear_port=cls.rear_port3, rear_port_position=2) + cls.front_port3_3 = FrontPort(device=device, name=f'FP3:3', rear_port=cls.rear_port3, rear_port_position=3) + cls.front_port3_4 = FrontPort(device=device, name=f'FP3:4', rear_port=cls.rear_port3, rear_port_position=4) + cls.front_port4_1 = FrontPort(device=device, name=f'FP4:1', rear_port=cls.rear_port4, rear_port_position=1) + cls.front_port4_2 = FrontPort(device=device, name=f'FP4:2', rear_port=cls.rear_port4, rear_port_position=2) + cls.front_port4_3 = FrontPort(device=device, name=f'FP4:3', rear_port=cls.rear_port4, rear_port_position=3) + cls.front_port4_4 = FrontPort(device=device, name=f'FP4:4', rear_port=cls.rear_port4, rear_port_position=4) + cls.front_port5_1 = FrontPort(device=device, name=f'FP5:1', rear_port=cls.rear_port5, rear_port_position=1) + cls.front_port6_1 = FrontPort(device=device, name=f'FP6:1', rear_port=cls.rear_port6, rear_port_position=1) + FrontPort.objects.bulk_create([ + cls.front_port1_1, + cls.front_port1_2, + cls.front_port1_3, + cls.front_port1_4, + cls.front_port2_1, + cls.front_port2_2, + cls.front_port2_3, + cls.front_port2_4, + cls.front_port3_1, + cls.front_port3_2, + cls.front_port3_3, + cls.front_port3_4, + cls.front_port4_1, + cls.front_port4_2, + cls.front_port4_3, + cls.front_port4_4, + cls.front_port5_1, + cls.front_port6_1, + ]) # Create four circuits with two terminations (A and Z) each (8 total) provider = Provider.objects.create(name='Provider', slug='provider') @@ -107,17 +136,17 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [IF2] """ # Create cable 1 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.interfaces[1]) + cable1 = Cable(termination_a=self.interface1, termination_b=self.interface2) cable1.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[1], + origin=self.interface1, + destination=self.interface2, path=(cable1,), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[0], + origin=self.interface2, + destination=self.interface1, path=(cable1,), is_connected=True ) @@ -134,29 +163,29 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ # Create cable 1 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[16]) + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) cable1.save() self.assertPathExists( - origin=self.interfaces[0], + origin=self.interface1, destination=None, - path=(cable1, self.front_ports[16], self.rear_ports[4]), + path=(cable1, self.front_port5_1, self.rear_port5), is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 2 - cable2 = Cable(termination_a=self.rear_ports[4], termination_b=self.interfaces[1]) + cable2 = Cable(termination_a=self.rear_port5, termination_b=self.interface2) cable2.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[1], - path=(cable1, self.front_ports[16], self.rear_ports[4], cable2), + origin=self.interface1, + destination=self.interface2, + path=(cable1, self.front_port5_1, self.rear_port5, cable2), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[0], - path=(cable2, self.rear_ports[4], self.front_ports[16], cable1), + origin=self.interface2, + destination=self.interface1, + path=(cable2, self.rear_port5, self.front_port5_1, cable1), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -164,9 +193,9 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() self.assertPathExists( - origin=self.interfaces[0], + origin=self.interface1, destination=None, - path=(cable1, self.front_ports[16], self.rear_ports[4]), + path=(cable1, self.front_port5_1, self.rear_port5), is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -177,78 +206,78 @@ class CablePathTestCase(TestCase): [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] """ # Create cables 1-2 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() - cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) cable2.save() self.assertPathExists( - origin=self.interfaces[0], + origin=self.interface1, destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0]), + path=(cable1, self.front_port1_1, self.rear_port1), is_connected=False ) self.assertPathExists( - origin=self.interfaces[1], + origin=self.interface2, destination=None, - path=(cable2, self.front_ports[1], self.rear_ports[0]), + path=(cable2, self.front_port1_2, self.rear_port1), is_connected=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cable 3 - cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.rear_ports[1]) + cable3 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) cable3.save() self.assertPathExists( - origin=self.interfaces[0], + origin=self.interface1, destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4]), + path=(cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1), is_connected=False ) self.assertPathExists( - origin=self.interfaces[1], + origin=self.interface2, destination=None, - path=(cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5]), + path=(cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2), is_connected=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cables 4-5 - cable4 = Cable(termination_a=self.front_ports[4], termination_b=self.interfaces[2]) + cable4 = Cable(termination_a=self.front_port2_1, termination_b=self.interface3) cable4.save() - cable5 = Cable(termination_a=self.front_ports[5], termination_b=self.interfaces[3]) + cable5 = Cable(termination_a=self.front_port2_2, termination_b=self.interface4) cable5.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[2], + origin=self.interface1, + destination=self.interface3, path=( - cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4], + cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1, cable4, ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[3], + origin=self.interface2, + destination=self.interface4, path=( - cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5], + cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2, cable5, ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[2], - destination=self.interfaces[0], + origin=self.interface3, + destination=self.interface1, path=( - cable4, self.front_ports[4], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[0], + cable4, self.front_port2_1, self.rear_port2, cable3, self.rear_port1, self.front_port1_1, cable1 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[3], - destination=self.interfaces[1], + origin=self.interface4, + destination=self.interface2, path=( - cable5, self.front_ports[5], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[1], + cable5, self.front_port2_2, self.rear_port2, cable3, self.rear_port1, self.front_port1_2, cable2 ), is_connected=True @@ -268,62 +297,62 @@ class CablePathTestCase(TestCase): [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] """ # Create cables 1-2, 6-7 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() - cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) cable2.save() - cable6 = Cable(termination_a=self.interfaces[2], termination_b=self.front_ports[12]) + cable6 = Cable(termination_a=self.interface3, termination_b=self.front_port4_1) cable6.save() - cable7 = Cable(termination_a=self.interfaces[3], termination_b=self.front_ports[13]) + cable7 = Cable(termination_a=self.interface4, termination_b=self.front_port4_2) cable7.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3 and 5 - cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.front_ports[4]) + cable3 = Cable(termination_a=self.rear_port1, termination_b=self.front_port2_1) cable3.save() - cable5 = Cable(termination_a=self.rear_ports[3], termination_b=self.front_ports[8]) + cable5 = Cable(termination_a=self.rear_port4, termination_b=self.front_port3_1) cable5.save() self.assertEqual(CablePath.objects.count(), 4) # Four (longer) partial paths; one from each interface # Create cable 4 - cable4 = Cable(termination_a=self.rear_ports[1], termination_b=self.rear_ports[2]) + cable4 = Cable(termination_a=self.rear_port2, termination_b=self.rear_port3) cable4.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[2], + origin=self.interface1, + destination=self.interface3, path=( - cable1, self.front_ports[0], self.rear_ports[0], cable3, self.front_ports[4], self.rear_ports[1], - cable4, self.rear_ports[2], self.front_ports[8], cable5, self.rear_ports[3], self.front_ports[12], + cable1, self.front_port1_1, self.rear_port1, cable3, self.front_port2_1, self.rear_port2, + cable4, self.rear_port3, self.front_port3_1, cable5, self.rear_port4, self.front_port4_1, cable6 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[3], + origin=self.interface2, + destination=self.interface4, path=( - cable2, self.front_ports[1], self.rear_ports[0], cable3, self.front_ports[4], self.rear_ports[1], - cable4, self.rear_ports[2], self.front_ports[8], cable5, self.rear_ports[3], self.front_ports[13], + cable2, self.front_port1_2, self.rear_port1, cable3, self.front_port2_1, self.rear_port2, + cable4, self.rear_port3, self.front_port3_1, cable5, self.rear_port4, self.front_port4_2, cable7 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[2], - destination=self.interfaces[0], + origin=self.interface3, + destination=self.interface1, path=( - cable6, self.front_ports[12], self.rear_ports[3], cable5, self.front_ports[8], self.rear_ports[2], - cable4, self.rear_ports[1], self.front_ports[4], cable3, self.rear_ports[0], self.front_ports[0], + cable6, self.front_port4_1, self.rear_port4, cable5, self.front_port3_1, self.rear_port3, + cable4, self.rear_port2, self.front_port2_1, cable3, self.rear_port1, self.front_port1_1, cable1 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[3], - destination=self.interfaces[1], + origin=self.interface4, + destination=self.interface2, path=( - cable7, self.front_ports[13], self.rear_ports[3], cable5, self.front_ports[8], self.rear_ports[2], - cable4, self.rear_ports[1], self.front_ports[4], cable3, self.rear_ports[0], self.front_ports[1], + cable7, self.front_port4_2, self.rear_port4, cable5, self.front_port3_1, self.rear_port3, + cable4, self.rear_port2, self.front_port2_1, cable3, self.rear_port1, self.front_port1_2, cable2 ), is_connected=True @@ -343,61 +372,61 @@ class CablePathTestCase(TestCase): [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] """ # Create cables 1-3, 6-8 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() - cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) + cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) cable2.save() - cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.rear_ports[1]) + cable3 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) cable3.save() - cable6 = Cable(termination_a=self.rear_ports[2], termination_b=self.rear_ports[3]) + cable6 = Cable(termination_a=self.rear_port3, termination_b=self.rear_port4) cable6.save() - cable7 = Cable(termination_a=self.interfaces[2], termination_b=self.front_ports[12]) + cable7 = Cable(termination_a=self.interface3, termination_b=self.front_port4_1) cable7.save() - cable8 = Cable(termination_a=self.interfaces[3], termination_b=self.front_ports[13]) + cable8 = Cable(termination_a=self.interface4, termination_b=self.front_port4_2) cable8.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 4 and 5 - cable4 = Cable(termination_a=self.front_ports[4], termination_b=self.front_ports[8]) + cable4 = Cable(termination_a=self.front_port2_1, termination_b=self.front_port3_1) cable4.save() - cable5 = Cable(termination_a=self.front_ports[5], termination_b=self.front_ports[9]) + cable5 = Cable(termination_a=self.front_port2_2, termination_b=self.front_port3_2) cable5.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[2], + origin=self.interface1, + destination=self.interface3, path=( - cable1, self.front_ports[0], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[4], - cable4, self.front_ports[8], self.rear_ports[2], cable6, self.rear_ports[3], self.front_ports[12], + cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1, + cable4, self.front_port3_1, self.rear_port3, cable6, self.rear_port4, self.front_port4_1, cable7 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[3], + origin=self.interface2, + destination=self.interface4, path=( - cable2, self.front_ports[1], self.rear_ports[0], cable3, self.rear_ports[1], self.front_ports[5], - cable5, self.front_ports[9], self.rear_ports[2], cable6, self.rear_ports[3], self.front_ports[13], + cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2, + cable5, self.front_port3_2, self.rear_port3, cable6, self.rear_port4, self.front_port4_2, cable8 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[2], - destination=self.interfaces[0], + origin=self.interface3, + destination=self.interface1, path=( - cable7, self.front_ports[12], self.rear_ports[3], cable6, self.rear_ports[2], self.front_ports[8], - cable4, self.front_ports[4], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[0], + cable7, self.front_port4_1, self.rear_port4, cable6, self.rear_port3, self.front_port3_1, + cable4, self.front_port2_1, self.rear_port2, cable3, self.rear_port1, self.front_port1_1, cable1 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[3], - destination=self.interfaces[1], + origin=self.interface4, + destination=self.interface2, path=( - cable8, self.front_ports[13], self.rear_ports[3], cable6, self.rear_ports[2], self.front_ports[9], - cable5, self.front_ports[5], self.rear_ports[1], cable3, self.rear_ports[0], self.front_ports[1], + cable8, self.front_port4_2, self.rear_port4, cable6, self.rear_port3, self.front_port3_2, + cable5, self.front_port2_2, self.rear_port2, cable3, self.rear_port1, self.front_port1_2, cable2 ), is_connected=True @@ -417,54 +446,54 @@ class CablePathTestCase(TestCase): [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] """ # Create cables 1-2, 5-6 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[0]) # IF1 -> FP1:1 + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) # IF1 -> FP1:1 cable1.save() - cable2 = Cable(termination_a=self.interfaces[1], termination_b=self.front_ports[1]) # IF2 -> FP1:2 + cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) # IF2 -> FP1:2 cable2.save() - cable5 = Cable(termination_a=self.interfaces[2], termination_b=self.front_ports[4]) # IF3 -> FP2:1 + cable5 = Cable(termination_a=self.interface3, termination_b=self.front_port2_1) # IF3 -> FP2:1 cable5.save() - cable6 = Cable(termination_a=self.interfaces[3], termination_b=self.front_ports[5]) # IF4 -> FP2:2 + cable6 = Cable(termination_a=self.interface4, termination_b=self.front_port2_2) # IF4 -> FP2:2 cable6.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3-4 - cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.front_ports[16]) # RP1 -> FP5 + cable3 = Cable(termination_a=self.rear_port1, termination_b=self.front_port5_1) # RP1 -> FP5 cable3.save() - cable4 = Cable(termination_a=self.rear_ports[4], termination_b=self.rear_ports[1]) # RP5 -> RP2 + cable4 = Cable(termination_a=self.rear_port5, termination_b=self.rear_port2) # RP5 -> RP2 cable4.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[2], + origin=self.interface1, + destination=self.interface3, path=( - cable1, self.front_ports[0], self.rear_ports[0], cable3, self.front_ports[16], self.rear_ports[4], - cable4, self.rear_ports[1], self.front_ports[4], cable5 + cable1, self.front_port1_1, self.rear_port1, cable3, self.front_port5_1, self.rear_port5, + cable4, self.rear_port2, self.front_port2_1, cable5 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[3], + origin=self.interface2, + destination=self.interface4, path=( - cable2, self.front_ports[1], self.rear_ports[0], cable3, self.front_ports[16], self.rear_ports[4], - cable4, self.rear_ports[1], self.front_ports[5], cable6 + cable2, self.front_port1_2, self.rear_port1, cable3, self.front_port5_1, self.rear_port5, + cable4, self.rear_port2, self.front_port2_2, cable6 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[2], - destination=self.interfaces[0], + origin=self.interface3, + destination=self.interface1, path=( - cable5, self.front_ports[4], self.rear_ports[1], cable4, self.rear_ports[4], self.front_ports[16], - cable3, self.rear_ports[0], self.front_ports[0], cable1 + cable5, self.front_port2_1, self.rear_port2, cable4, self.rear_port5, self.front_port5_1, + cable3, self.rear_port1, self.front_port1_1, cable1 ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[3], - destination=self.interfaces[1], + origin=self.interface4, + destination=self.interface2, path=( - cable6, self.front_ports[5], self.rear_ports[1], cable4, self.rear_ports[4], self.front_ports[16], - cable3, self.rear_ports[0], self.front_ports[1], cable2 + cable6, self.front_port2_2, self.rear_port2, cable4, self.rear_port5, self.front_port5_1, + cable3, self.rear_port1, self.front_port1_2, cable2 ), is_connected=True ) @@ -482,38 +511,38 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] """ # Create cable 2 - cable2 = Cable(termination_a=self.rear_ports[4], termination_b=self.rear_ports[5]) + cable2 = Cable(termination_a=self.rear_port5, termination_b=self.rear_port6) cable2.save() self.assertEqual(CablePath.objects.count(), 0) # Create cable1 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[16]) + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) cable1.save() self.assertPathExists( - origin=self.interfaces[0], + origin=self.interface1, destination=None, - path=(cable1, self.front_ports[16], self.rear_ports[4], cable2, self.rear_ports[5], self.front_ports[17]), + path=(cable1, self.front_port5_1, self.rear_port5, cable2, self.rear_port6, self.front_port6_1), is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 3 - cable3 = Cable(termination_a=self.front_ports[17], termination_b=self.interfaces[1]) + cable3 = Cable(termination_a=self.front_port6_1, termination_b=self.interface2) cable3.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[1], + origin=self.interface1, + destination=self.interface2, path=( - cable1, self.front_ports[16], self.rear_ports[4], cable2, self.rear_ports[5], self.front_ports[17], + cable1, self.front_port5_1, self.rear_port5, cable2, self.rear_port6, self.front_port6_1, cable3, ), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[0], + origin=self.interface2, + destination=self.interface1, path=( - cable3, self.front_ports[17], self.rear_ports[5], cable2, self.rear_ports[4], self.front_ports[16], + cable3, self.front_port6_1, self.rear_port6, cable2, self.rear_port5, self.front_port5_1, cable1, ), is_connected=True @@ -525,9 +554,9 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ # Create cables 1 and 2 - cable1 = Cable(termination_a=self.interfaces[0], termination_b=self.front_ports[16]) + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) cable1.save() - cable2 = Cable(termination_a=self.rear_ports[4], termination_b=self.interfaces[1]) + cable2 = Cable(termination_a=self.rear_port5, termination_b=self.interface2) cable2.save() self.assertEqual(CablePath.objects.filter(is_connected=True).count(), 2) self.assertEqual(CablePath.objects.count(), 2) @@ -536,15 +565,15 @@ class CablePathTestCase(TestCase): cable2.status = CableStatusChoices.STATUS_PLANNED cable2.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[1], - path=(cable1, self.front_ports[16], self.rear_ports[4], cable2), + origin=self.interface1, + destination=self.interface2, + path=(cable1, self.front_port5_1, self.rear_port5, cable2), is_connected=False ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[0], - path=(cable2, self.rear_ports[4], self.front_ports[16], cable1), + origin=self.interface2, + destination=self.interface1, + path=(cable2, self.rear_port5, self.front_port5_1, cable1), is_connected=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -554,15 +583,15 @@ class CablePathTestCase(TestCase): cable2.status = CableStatusChoices.STATUS_CONNECTED cable2.save() self.assertPathExists( - origin=self.interfaces[0], - destination=self.interfaces[1], - path=(cable1, self.front_ports[16], self.rear_ports[4], cable2), + origin=self.interface1, + destination=self.interface2, + path=(cable1, self.front_port5_1, self.rear_port5, cable2), is_connected=True ) self.assertPathExists( - origin=self.interfaces[1], - destination=self.interfaces[0], - path=(cable2, self.rear_ports[4], self.front_ports[16], cable1), + origin=self.interface2, + destination=self.interface1, + path=(cable2, self.rear_port5, self.front_port5_1, cable1), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) From 4fd12198144f2da3fad3422493a365bb12cfb331 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 11:35:17 -0400 Subject: [PATCH 082/291] Add tests for all PathEndpoint classes --- netbox/dcim/tests/test_cablepaths.py | 161 ++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 53ce82403..18c15fd16 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -8,7 +8,14 @@ from dcim.utils import objects_to_path class CablePathTestCase(TestCase): + """ + Test NetBox's ability to trace and retrace CablePaths in response to data model changes. Tests are numbered + as follows: + 1XX: Test direct connections between different endpoint types + 2XX: Test different cable topologies + 3XX: Test responses to changes in existing objects + """ @classmethod def setUpTestData(cls): @@ -19,6 +26,12 @@ class CablePathTestCase(TestCase): device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') device = Device.objects.create(site=site, device_type=device_type, device_role=device_role, name='Test Device') + # Create console/power components for testing + cls.consoleport1 = ConsolePort.objects.create(device=device, name='Console Port 1') + cls.consoleserverport1 = ConsoleServerPort.objects.create(device=device, name='Console Server Port 1') + cls.powerport1 = PowerPort.objects.create(device=device, name='Power Port 1') + cls.poweroutlet1 = PowerPort.objects.create(device=device, name='Power Outlet 1') + # Create 4 interfaces for testing cls.interface1 = Interface(device=device, name=f'Interface 1') cls.interface2 = Interface(device=device, name=f'Interface 2') @@ -87,18 +100,28 @@ class CablePathTestCase(TestCase): cls.front_port6_1, ]) - # Create four circuits with two terminations (A and Z) each (8 total) + # Create a PowerFeed for testing + powerpanel = PowerPanel.objects.create(site=site, name='Power Panel') + cls.powerfeed1 = PowerFeed.objects.create(power_panel=powerpanel, name='Power Feed 1') + + # Create four CircuitTerminations for testing provider = Provider.objects.create(name='Provider', slug='provider') circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') circuits = [ - Circuit(provider=provider, type=circuit_type, cid=f'Circuit {i}') for i in range(1, 5) + Circuit(provider=provider, type=circuit_type, cid='Circuit 1'), + Circuit(provider=provider, type=circuit_type, cid='Circuit 2'), ] Circuit.objects.bulk_create(circuits) - cls.circuit_terminations = [ - *[CircuitTermination(circuit=circuit, site=site, term_side='A', port_speed=1000) for circuit in circuits], - *[CircuitTermination(circuit=circuit, site=site, term_side='Z', port_speed=1000) for circuit in circuits], - ] - CircuitTermination.objects.bulk_create(cls.circuit_terminations) + cls.circuittermination1_A = CircuitTermination(circuit=circuits[0], site=site, term_side='A', port_speed=1000) + cls.circuittermination1_Z = CircuitTermination(circuit=circuits[0], site=site, term_side='Z', port_speed=1000) + cls.circuittermination2_A = CircuitTermination(circuit=circuits[1], site=site, term_side='A', port_speed=1000) + cls.circuittermination2_Z = CircuitTermination(circuit=circuits[1], site=site, term_side='Z', port_speed=1000) + CircuitTermination.objects.bulk_create([ + cls.circuittermination1_A, + cls.circuittermination1_Z, + cls.circuittermination2_A, + cls.circuittermination2_Z, + ]) def assertPathExists(self, origin, destination, path=None, is_connected=None, msg=None): """ @@ -131,7 +154,7 @@ class CablePathTestCase(TestCase): msg = f"Missing partial path originating from {origin}" self.assertEqual(CablePath.objects.filter(**kwargs).count(), 1, msg=msg) - def test_01_interface_to_interface(self): + def test_101_interface_to_interface(self): """ [IF1] --C1-- [IF2] """ @@ -158,7 +181,115 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_02_interface_to_interface_via_pass_through(self): + def test_103_consoleport_to_consoleserverport(self): + """ + [CP1] --C1-- [CSP1] + """ + # Create cable 1 + cable1 = Cable(termination_a=self.consoleport1, termination_b=self.consoleserverport1) + cable1.save() + self.assertPathExists( + origin=self.consoleport1, + destination=self.consoleserverport1, + path=(cable1,), + is_connected=True + ) + self.assertPathExists( + origin=self.consoleserverport1, + destination=self.consoleport1, + path=(cable1,), + is_connected=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_104_powerport_to_poweroutlet(self): + """ + [PP1] --C1-- [PO1] + """ + # Create cable 1 + cable1 = Cable(termination_a=self.powerport1, termination_b=self.poweroutlet1) + cable1.save() + self.assertPathExists( + origin=self.powerport1, + destination=self.poweroutlet1, + path=(cable1,), + is_connected=True + ) + self.assertPathExists( + origin=self.poweroutlet1, + destination=self.powerport1, + path=(cable1,), + is_connected=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_105_powerport_to_powerfeed(self): + """ + [PP1] --C1-- [PF1] + """ + # Create cable 1 + cable1 = Cable(termination_a=self.powerport1, termination_b=self.powerfeed1) + cable1.save() + self.assertPathExists( + origin=self.powerport1, + destination=self.powerfeed1, + path=(cable1,), + is_connected=True + ) + self.assertPathExists( + origin=self.powerfeed1, + destination=self.powerport1, + path=(cable1,), + is_connected=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_106_interface_to_circuittermination(self): + """ + [PP1] --C1-- [CT1A] + """ + # Create cable 1 + cable1 = Cable(termination_a=self.interface1, termination_b=self.circuittermination1_A) + cable1.save() + self.assertPathExists( + origin=self.interface1, + destination=self.circuittermination1_A, + path=(cable1,), + is_connected=True + ) + self.assertPathExists( + origin=self.circuittermination1_A, + destination=self.interface1, + path=(cable1,), + is_connected=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_201_single_path_via_pass_through(self): """ [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ @@ -200,7 +331,7 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 1) - def test_03_interfaces_to_interfaces_via_pass_through(self): + def test_202_multiple_paths_via_pass_through(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] @@ -291,7 +422,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) - def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): + def test_203_multiple_paths_via_nested_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2:1] [RP2] --C4-- [RP3] [FP3:1] --C5-- [RP4] [FP4:1] --C6-- [IF3] [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] @@ -366,7 +497,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) - def test_05_interfaces_to_interfaces_via_multiple_pass_throughs(self): + def test_204_multiple_paths_via_multiple_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3] [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] @@ -440,7 +571,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 2) - def test_06_interfaces_to_interfaces_via_patched_pass_throughs(self): + def test_205_multiple_paths_via_patched_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP5] [RP5] --C4-- [RP2] [FP2:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] @@ -506,7 +637,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) - def test_07_interface_to_interface_via_existing_cable(self): + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] """ @@ -549,7 +680,7 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) - def test_08_change_cable_status(self): + def test_302_update_path_on_cable_status_change(self): """ [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ From e0abd7ef3ec52c163ed86a68141ff23800731873 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 11:45:42 -0400 Subject: [PATCH 083/291] Remove dcim.tests.test_api.ConnectionTest --- netbox/dcim/tests/test_api.py | 371 ---------------------------------- 1 file changed, 371 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 512d7919c..528301f8f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1453,377 +1453,6 @@ class CableTest(APIViewTestCases.APIViewTestCase): ] -class ConnectionTest(APITestCase): - - def setUp(self): - - super().setUp() - - self.site = Site.objects.create( - name='Test Site 1', slug='test-site-1' - ) - manufacturer = Manufacturer.objects.create( - name='Test Manufacturer 1', slug='test-manufacturer-1' - ) - devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' - ) - devicerole = DeviceRole.objects.create( - name='Test Device Role 1', slug='test-device-role-1', color='ff0000' - ) - self.device1 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site - ) - self.device2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site - ) - self.panel1 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=self.site - ) - self.panel2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=self.site - ) - - def test_create_direct_console_connection(self): - - consoleport1 = ConsolePort.objects.create( - device=self.device1, name='Test Console Port 1' - ) - consoleserverport1 = ConsoleServerPort.objects.create( - device=self.device2, name='Test Console Server Port 1' - ) - - data = { - 'termination_a_type': 'dcim.consoleport', - 'termination_a_id': consoleport1.pk, - 'termination_b_type': 'dcim.consoleserverport', - 'termination_b_id': consoleserverport1.pk, - } - - self.add_permissions('dcim.add_cable') - url = reverse('dcim-api:cable-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) - - cable = Cable.objects.get(pk=response.data['id']) - consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) - consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) - - self.assertEqual(cable.termination_a, consoleport1) - self.assertEqual(cable.termination_b, consoleserverport1) - self.assertEqual(consoleport1.cable, cable) - self.assertEqual(consoleserverport1.cable, cable) - self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) - self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) - - def test_create_patched_console_connection(self): - - consoleport1 = ConsolePort.objects.create( - device=self.device1, name='Test Console Port 1' - ) - consoleserverport1 = ConsoleServerPort.objects.create( - device=self.device2, name='Test Console Server Port 1' - ) - rearport1 = RearPort.objects.create( - device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C - ) - frontport1 = FrontPort.objects.create( - device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1 - ) - rearport2 = RearPort.objects.create( - device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C - ) - frontport2 = FrontPort.objects.create( - device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2 - ) - - self.add_permissions('dcim.add_cable') - url = reverse('dcim-api:cable-list') - cables = [ - # Console port to panel1 front - { - 'termination_a_type': 'dcim.consoleport', - 'termination_a_id': consoleport1.pk, - 'termination_b_type': 'dcim.frontport', - 'termination_b_id': frontport1.pk, - }, - # Panel1 rear to panel2 rear - { - 'termination_a_type': 'dcim.rearport', - 'termination_a_id': rearport1.pk, - 'termination_b_type': 'dcim.rearport', - 'termination_b_id': rearport2.pk, - }, - # Panel2 front to console server port - { - 'termination_a_type': 'dcim.frontport', - 'termination_a_id': frontport2.pk, - 'termination_b_type': 'dcim.consoleserverport', - 'termination_b_id': consoleserverport1.pk, - }, - ] - - for data in cables: - - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - - cable = Cable.objects.get(pk=response.data['id']) - self.assertEqual(cable.termination_a.cable, cable) - self.assertEqual(cable.termination_b.cable, cable) - - consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) - consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) - self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) - self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) - - def test_create_direct_power_connection(self): - - powerport1 = PowerPort.objects.create( - device=self.device1, name='Test Power Port 1' - ) - poweroutlet1 = PowerOutlet.objects.create( - device=self.device2, name='Test Power Outlet 1' - ) - - data = { - 'termination_a_type': 'dcim.powerport', - 'termination_a_id': powerport1.pk, - 'termination_b_type': 'dcim.poweroutlet', - 'termination_b_id': poweroutlet1.pk, - } - - self.add_permissions('dcim.add_cable') - url = reverse('dcim-api:cable-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) - - cable = Cable.objects.get(pk=response.data['id']) - powerport1 = PowerPort.objects.get(pk=powerport1.pk) - poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk) - - self.assertEqual(cable.termination_a, powerport1) - self.assertEqual(cable.termination_b, poweroutlet1) - self.assertEqual(powerport1.cable, cable) - self.assertEqual(poweroutlet1.cable, cable) - self.assertEqual(powerport1.connected_endpoint, poweroutlet1) - self.assertEqual(poweroutlet1.connected_endpoint, powerport1) - - # Note: Power connections via patch ports are not supported. - - def test_create_direct_interface_connection(self): - - interface1 = Interface.objects.create( - device=self.device1, name='Test Interface 1' - ) - interface2 = Interface.objects.create( - device=self.device2, name='Test Interface 2' - ) - - data = { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interface1.pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interface2.pk, - } - - self.add_permissions('dcim.add_cable') - url = reverse('dcim-api:cable-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) - - cable = Cable.objects.get(pk=response.data['id']) - interface1 = Interface.objects.get(pk=interface1.pk) - interface2 = Interface.objects.get(pk=interface2.pk) - - self.assertEqual(cable.termination_a, interface1) - self.assertEqual(cable.termination_b, interface2) - self.assertEqual(interface1.cable, cable) - self.assertEqual(interface2.cable, cable) - self.assertEqual(interface1.connected_endpoint, interface2) - self.assertEqual(interface2.connected_endpoint, interface1) - - def test_create_patched_interface_connection(self): - - interface1 = Interface.objects.create( - device=self.device1, name='Test Interface 1' - ) - interface2 = Interface.objects.create( - device=self.device2, name='Test Interface 2' - ) - rearport1 = RearPort.objects.create( - device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C - ) - frontport1 = FrontPort.objects.create( - device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1 - ) - rearport2 = RearPort.objects.create( - device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C - ) - frontport2 = FrontPort.objects.create( - device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2 - ) - - self.add_permissions('dcim.add_cable') - url = reverse('dcim-api:cable-list') - cables = [ - # Interface1 to panel1 front - { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interface1.pk, - 'termination_b_type': 'dcim.frontport', - 'termination_b_id': frontport1.pk, - }, - # Panel1 rear to panel2 rear - { - 'termination_a_type': 'dcim.rearport', - 'termination_a_id': rearport1.pk, - 'termination_b_type': 'dcim.rearport', - 'termination_b_id': rearport2.pk, - }, - # Panel2 front to interface2 - { - 'termination_a_type': 'dcim.frontport', - 'termination_a_id': frontport2.pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interface2.pk, - }, - ] - - for data in cables: - - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - - cable = Cable.objects.get(pk=response.data['id']) - self.assertEqual(cable.termination_a.cable, cable) - self.assertEqual(cable.termination_b.cable, cable) - - interface1 = Interface.objects.get(pk=interface1.pk) - interface2 = Interface.objects.get(pk=interface2.pk) - self.assertEqual(interface1.connected_endpoint, interface2) - self.assertEqual(interface2.connected_endpoint, interface1) - - def test_create_direct_circuittermination_connection(self): - - provider = Provider.objects.create( - name='Test Provider 1', slug='test-provider-1' - ) - circuittype = CircuitType.objects.create( - name='Test Circuit Type 1', slug='test-circuit-type-1' - ) - circuit = Circuit.objects.create( - provider=provider, type=circuittype, cid='Test Circuit 1' - ) - interface1 = Interface.objects.create( - device=self.device1, name='Test Interface 1' - ) - circuittermination1 = CircuitTermination.objects.create( - circuit=circuit, term_side='A', site=self.site, port_speed=10000 - ) - - data = { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interface1.pk, - 'termination_b_type': 'circuits.circuittermination', - 'termination_b_id': circuittermination1.pk, - } - - self.add_permissions('dcim.add_cable') - url = reverse('dcim-api:cable-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) - - cable = Cable.objects.get(pk=response.data['id']) - interface1 = Interface.objects.get(pk=interface1.pk) - circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) - - self.assertEqual(cable.termination_a, interface1) - self.assertEqual(cable.termination_b, circuittermination1) - self.assertEqual(interface1.cable, cable) - self.assertEqual(circuittermination1.cable, cable) - self.assertEqual(interface1.connected_endpoint, circuittermination1) - self.assertEqual(circuittermination1.connected_endpoint, interface1) - - def test_create_patched_circuittermination_connection(self): - - provider = Provider.objects.create( - name='Test Provider 1', slug='test-provider-1' - ) - circuittype = CircuitType.objects.create( - name='Test Circuit Type 1', slug='test-circuit-type-1' - ) - circuit = Circuit.objects.create( - provider=provider, type=circuittype, cid='Test Circuit 1' - ) - interface1 = Interface.objects.create( - device=self.device1, name='Test Interface 1' - ) - circuittermination1 = CircuitTermination.objects.create( - circuit=circuit, term_side='A', site=self.site, port_speed=10000 - ) - rearport1 = RearPort.objects.create( - device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C - ) - frontport1 = FrontPort.objects.create( - device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1 - ) - rearport2 = RearPort.objects.create( - device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C - ) - frontport2 = FrontPort.objects.create( - device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2 - ) - - self.add_permissions('dcim.add_cable') - url = reverse('dcim-api:cable-list') - cables = [ - # Interface to panel1 front - { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interface1.pk, - 'termination_b_type': 'dcim.frontport', - 'termination_b_id': frontport1.pk, - }, - # Panel1 rear to panel2 rear - { - 'termination_a_type': 'dcim.rearport', - 'termination_a_id': rearport1.pk, - 'termination_b_type': 'dcim.rearport', - 'termination_b_id': rearport2.pk, - }, - # Panel2 front to circuit termination - { - 'termination_a_type': 'dcim.frontport', - 'termination_a_id': frontport2.pk, - 'termination_b_type': 'circuits.circuittermination', - 'termination_b_id': circuittermination1.pk, - }, - ] - - for data in cables: - - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - - cable = Cable.objects.get(pk=response.data['id']) - self.assertEqual(cable.termination_a.cable, cable) - self.assertEqual(cable.termination_b.cable, cable) - - interface1 = Interface.objects.get(pk=interface1.pk) - circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) - self.assertEqual(interface1.connected_endpoint, circuittermination1) - self.assertEqual(circuittermination1.connected_endpoint, interface1) - - class ConnectedDeviceTest(APITestCase): def setUp(self): From 66355da04c87ab35b7b0a4f53039e5e542a871bd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 11:51:23 -0400 Subject: [PATCH 084/291] CablePath.origin should be unique --- netbox/dcim/migrations/0120_cablepath.py | 7 ++++++- netbox/dcim/models/devices.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py index 2cb8376b7..f3448e747 100644 --- a/netbox/dcim/migrations/0120_cablepath.py +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -1,3 +1,5 @@ +# Generated by Django 3.1 on 2020-10-02 15:49 + import dcim.fields from django.db import migrations, models import django.db.models.deletion @@ -18,9 +20,12 @@ class Migration(migrations.Migration): ('origin_id', models.PositiveIntegerField()), ('destination_id', models.PositiveIntegerField(blank=True, null=True)), ('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)), + ('is_connected', models.BooleanField(default=False)), ('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), ('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), - ('is_connected', models.BooleanField(default=False)), ], + options={ + 'unique_together': {('origin_type', 'origin_id')}, + }, ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 0cb1ea970..52627dc7d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1197,6 +1197,9 @@ class CablePath(models.Model): objects = CablePathManager() + class Meta: + unique_together = ('origin_type', 'origin_id') + def __str__(self): path = ', '.join([str(path_node_to_object(node)) for node in self.path]) return f"Path #{self.pk}: {self.origin} to {self.destination} via ({path})" From aa0d4c4145f9ffd74711fb7aadcdbbb36a4c3bea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 12:25:31 -0400 Subject: [PATCH 085/291] Replace connection_status filter with is_connected --- netbox/circuits/filters.py | 3 ++- netbox/dcim/filters.py | 37 ++++++++++++++++++++++--------- netbox/dcim/tests/test_filters.py | 25 +++++++++------------ 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 206dcc305..ebc0d0ec1 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,6 +1,7 @@ import django_filters from django.db.models import Q +from dcim.filters import PathEndpointFilterSet from dcim.models import Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet @@ -144,7 +145,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr ).distinct() -class CircuitTerminationFilterSet(BaseFilterSet): +class CircuitTerminationFilterSet(BaseFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 457483273..c76bd3b87 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,5 +1,6 @@ import django_filters from django.contrib.auth.models import User +from django.db.models import Count from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet @@ -752,7 +753,21 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) -class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class PathEndpointFilterSet(django_filters.FilterSet): + is_connected = django_filters.BooleanFilter( + method='filter_is_connected', + label='Search', + ) + + def filter_is_connected(self, queryset, name, value): + kwargs = {'connected_paths': 1 if value else 0} + # TODO: Boolean rather than Count()? + return queryset.annotate( + connected_paths=Count('_paths', filter=Q(_paths__is_connected=True)) + ).filter(**kwargs) + + +class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -765,10 +780,10 @@ class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = ConsolePort - fields = ['id', 'name', 'description', 'connection_status'] + fields = ['id', 'name', 'description'] -class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -781,10 +796,10 @@ class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'description', 'connection_status'] + fields = ['id', 'name', 'description'] -class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -797,10 +812,10 @@ class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = PowerPort - fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -813,10 +828,10 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = PowerOutlet - fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] + fields = ['id', 'name', 'feed_leg', 'description'] -class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): +class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -864,7 +879,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = Interface - fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] + fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] def filter_device(self, queryset, name, value): try: @@ -1284,7 +1299,7 @@ class PowerPanelFilterSet(BaseFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class PowerFeedFilterSet(BaseFilterSet, PathEndpointFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 0a2794f01..c399e1a92 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1514,9 +1514,8 @@ class ConsolePortTestCase(TestCase): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - # TODO: Fix boolean value - def test_connection_status(self): - params = {'connection_status': 'True'} + def test_is_connected(self): + params = {'is_connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_region(self): @@ -1609,9 +1608,8 @@ class ConsoleServerPortTestCase(TestCase): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - # TODO: Fix boolean value - def test_connection_status(self): - params = {'connection_status': 'True'} + def test_is_connected(self): + params = {'is_connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_region(self): @@ -1712,9 +1710,8 @@ class PowerPortTestCase(TestCase): params = {'allocated_draw': [50, 100]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - # TODO: Fix boolean value - def test_connection_status(self): - params = {'connection_status': 'True'} + def test_is_connected(self): + params = {'is_connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_region(self): @@ -1812,9 +1809,8 @@ class PowerOutletTestCase(TestCase): params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - # TODO: Fix boolean value - def test_connection_status(self): - params = {'connection_status': 'True'} + def test_is_connected(self): + params = {'is_connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_region(self): @@ -1900,9 +1896,8 @@ class InterfaceTestCase(TestCase): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - # TODO: Fix boolean value - def test_connection_status(self): - params = {'connection_status': 'True'} + def test_is_connected(self): + params = {'is_connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_enabled(self): From e9da84f91aaf80aced3b369d4205165572a62dc5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 14:54:16 -0400 Subject: [PATCH 086/291] Replace legacy trace() method --- netbox/circuits/urls.py | 4 +- netbox/dcim/api/views.py | 18 +-- netbox/dcim/models/device_components.py | 140 ++---------------------- netbox/dcim/urls.py | 14 +-- netbox/dcim/utils.py | 3 +- netbox/dcim/views.py | 8 +- netbox/templates/dcim/cable_trace.html | 55 +--------- 7 files changed, 38 insertions(+), 204 deletions(-) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 86ea55fa8..d757fd90d 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from dcim.views import CableCreateView, CableTraceView +from dcim.views import CableCreateView, PathTraceView from extras.views import ObjectChangeLogView from . import views from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -45,6 +45,6 @@ urlpatterns = [ path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), - path('circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), + path('circuit-terminations//trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0583d4e56..edbdfb7be 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -45,7 +45,7 @@ class DCIMRootView(APIRootView): # Mixins -class CableTraceMixin(object): +class PathEndpointMixin(object): @action(detail=True, url_path='trace') def trace(self, request, pk): @@ -57,7 +57,7 @@ class CableTraceMixin(object): # Initialize the path array path = [] - for near_end, cable, far_end in obj.trace()[0]: + for near_end, cable, far_end in obj.trace(): # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') @@ -469,19 +469,19 @@ class DeviceViewSet(CustomFieldModelViewSet): # Device components # -class ConsolePortViewSet(CableTraceMixin, ModelViewSet): +class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsolePortFilterSet -class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): +class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filters.ConsoleServerPortFilterSet -class PowerPortViewSet(CableTraceMixin, ModelViewSet): +class PowerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = PowerPort.objects.prefetch_related( 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags' ) @@ -489,13 +489,13 @@ class PowerPortViewSet(CableTraceMixin, ModelViewSet): filterset_class = filters.PowerPortFilterSet -class PowerOutletViewSet(CableTraceMixin, ModelViewSet): +class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filters.PowerOutletFilterSet -class InterfaceViewSet(CableTraceMixin, ModelViewSet): +class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags' ).filter( @@ -505,13 +505,13 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): filterset_class = filters.InterfaceFilterSet -class FrontPortViewSet(CableTraceMixin, ModelViewSet): +class FrontPortViewSet(ModelViewSet): queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') serializer_class = serializers.FrontPortSerializer filterset_class = filters.FrontPortFilterSet -class RearPortViewSet(CableTraceMixin, ModelViewSet): +class RearPortViewSet(ModelViewSet): queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') serializer_class = serializers.RearPortSerializer filterset_class = filters.RearPortFilterSet diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 6bf2ac77a..b714c662a 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,5 +1,3 @@ -import logging - from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -11,8 +9,8 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from dcim.exceptions import CableTraceSplit from dcim.fields import MACAddressField +from dcim.utils import path_node_to_object from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features from utilities.fields import NaturalOrderingField @@ -117,114 +115,6 @@ class CableTermination(models.Model): class Meta: abstract = True - def trace(self): - """ - Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and - the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint - along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible - to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses - a FrontPort without traversing a RearPort again. - - The path is a list representing a complete cable path, with each individual segment represented as a - three-tuple: - - [ - (termination A, cable, termination B), - (termination C, cable, termination D), - (termination E, cable, termination F) - ] - """ - endpoint = self - path = [] - position_stack = [] - - def get_peer_port(termination): - from circuits.models import CircuitTermination - - # Map a front port to its corresponding rear port - if isinstance(termination, FrontPort): - # Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance - peer_port = RearPort.objects.get(pk=termination.rear_port.pk) - - # Don't use the stack for RearPorts with a single position. Only remember the position at - # many-to-one points so we can select the correct FrontPort when we reach the corresponding - # one-to-many point. - if peer_port.positions > 1: - position_stack.append(termination) - - return peer_port - - # Map a rear port/position to its corresponding front port - elif isinstance(termination, RearPort): - if termination.positions > 1: - # Can't map to a FrontPort without a position if there are multiple options - if not position_stack: - raise CableTraceSplit(termination) - - front_port = position_stack.pop() - position = front_port.rear_port_position - - # Validate the position - if position not in range(1, termination.positions + 1): - raise Exception("Invalid position for {} ({} positions): {})".format( - termination, termination.positions, position - )) - else: - # Don't use the stack for RearPorts with a single position. The only possible position is 1. - position = 1 - - try: - peer_port = FrontPort.objects.get( - rear_port=termination, - rear_port_position=position, - ) - return peer_port - except ObjectDoesNotExist: - return None - - # Follow a circuit to its other termination - elif isinstance(termination, CircuitTermination): - peer_termination = termination.get_peer_termination() - if peer_termination is None: - return None - return peer_termination - - # Termination is not a pass-through port - else: - return None - - logger = logging.getLogger('netbox.dcim.cable.trace') - logger.debug("Tracing cable from {} {}".format(self.parent, self)) - - while endpoint is not None: - - # No cable connected; nothing to trace - if not endpoint.cable: - path.append((endpoint, None, None)) - logger.debug("No cable connected") - return path, None, position_stack - - # Check for loops - if endpoint.cable in [segment[1] for segment in path]: - logger.debug("Loop detected!") - return path, None, position_stack - - # Record the current segment in the path - far_end = endpoint.get_cable_peer() - path.append((endpoint, endpoint.cable, far_end)) - logger.debug("{}[{}] --- Cable {} ---> {}[{}]".format( - endpoint.parent, endpoint, endpoint.cable.pk, far_end.parent, far_end - )) - - # Get the peer port of the far end termination - try: - endpoint = get_peer_port(far_end) - except CableTraceSplit as e: - return path, e.termination.frontports.all(), position_stack - - if endpoint is None: - return path, None, position_stack - def get_cable_peer(self): if self.cable is None: return None @@ -233,23 +123,6 @@ class CableTermination(models.Model): if self._cabled_as_b.exists(): return self.cable.termination_a - def get_path_endpoints(self): - """ - Return all endpoints of paths which traverse this object. - """ - endpoints = [] - - # Get the far end of the last path segment - path, split_ends, position_stack = self.trace() - endpoint = path[-1][2] - if split_ends is not None: - for termination in split_ends: - endpoints.extend(termination.get_path_endpoints()) - elif endpoint is not None: - endpoints.append(endpoint) - - return endpoints - class PathEndpoint(models.Model): """ @@ -265,6 +138,17 @@ class PathEndpoint(models.Model): class Meta: abstract = True + def trace(self): + if self.path is None: + return [] + + # Construct the complete path + path = [self, *[path_node_to_object(obj) for obj in self.path.path], self.path.destination] + assert not len(path) % 3, f"Invalid path length for CablePath #{self.pk}: {len(self.path)} elements in path" + + # Return the path as a list of three-tuples (A termination, cable, B termination) + return list(zip(*[iter(path)] * 3)) + @property def path(self): """ diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index aa0453baf..ba58cf67e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -207,7 +207,7 @@ urlpatterns = [ path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), - path('console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), @@ -223,7 +223,7 @@ urlpatterns = [ path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), - path('console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), @@ -239,7 +239,7 @@ urlpatterns = [ path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), - path('power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), @@ -255,7 +255,7 @@ urlpatterns = [ path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), - path('power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), @@ -271,7 +271,7 @@ urlpatterns = [ path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), - path('interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), @@ -287,7 +287,7 @@ urlpatterns = [ path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), - path('front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), @@ -303,7 +303,7 @@ urlpatterns = [ path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), - path('rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 16d0753ba..4ef902dc7 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType from .choices import CableStatusChoices from .exceptions import CableTraceSplit -from .models import FrontPort, RearPort def object_to_path_node(obj): @@ -20,6 +19,8 @@ def path_node_to_object(repr): def trace_path(node): + from .models import FrontPort, RearPort + destination = None path = [] position_stack = [] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 96e6615e8..ac30461b0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1961,9 +1961,9 @@ class CableView(ObjectView): }) -class CableTraceView(ObjectView): +class PathTraceView(ObjectView): """ - Trace a cable path beginning from the given termination. + Trace a cable path beginning from the given path endpoint (origin). """ additional_permissions = ['dcim.view_cable'] @@ -1976,7 +1976,7 @@ class CableTraceView(ObjectView): def get(self, request, pk): obj = get_object_or_404(self.queryset, pk=pk) - path, split_ends, position_stack = obj.trace() + path = obj.trace() total_length = sum( [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] ) @@ -1984,8 +1984,6 @@ class CableTraceView(ObjectView): return render(request, 'dcim/cable_trace.html', { 'obj': obj, 'trace': path, - 'split_ends': split_ends, - 'position_stack': position_stack, 'total_length': total_length, }) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index df484609a..2f54f94ee 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -51,57 +51,8 @@
    {% endfor %}
    - {% if split_ends %} -
    -
    -
    - Trace Split -
    -
    - There are multiple possible paths from this point. Select a port to continue. -
    -
    -
    - - - - - - - - - - {% for termination in split_ends %} - - - - - - - {% endfor %} -
    PortConnectedTypeDescription
    {{ termination }} - {% if termination.cable %} - - {% else %} - - {% endif %} - {{ termination.get_type_display }}{{ termination.description|placeholder }}
    -
    -
    - {% elif position_stack %} -
    -

    - {% with last_position=position_stack|last %} - Trace completed, but there is no Front Port corresponding to - {{ last_position.device }} {{ last_position }}.
    - Therefore no end-to-end connection can be established. - {% endwith %} -

    -
    - {% else %} -
    -

    Trace completed!

    -
    - {% endif %} +
    +

    Trace completed!

    +
    {% endblock %} From 7ff247c57ff683676b6ecc52b52f755c440dbe22 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 14:57:50 -0400 Subject: [PATCH 087/291] Add trace view for PowerFeed --- netbox/dcim/urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ba58cf67e..90f6b5ef2 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -383,6 +383,7 @@ urlpatterns = [ path('power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), + path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), ] From 8cb636bed2c5f6355003aa8509ab70e1b52f4519 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 15:10:49 -0400 Subject: [PATCH 088/291] Update console/power/interface connection tables --- netbox/dcim/tables.py | 58 ++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 7af030a03..7ab08eae4 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -812,13 +812,15 @@ class CableTable(BaseTable): # class ConsoleConnectionTable(BaseTable): - console_server = tables.LinkColumn( - viewname='dcim:device', - accessor=Accessor('connected_endpoint__device'), - args=[Accessor('connected_endpoint__device__pk')], + console_server = tables.Column( + accessor=Accessor('path__destination__device'), + orderable=False, + linkify=True, verbose_name='Console Server' ) - connected_endpoint = tables.Column( + console_server_port = tables.Column( + accessor=Accessor('path__destination'), + orderable=False, linkify=True, verbose_name='Port' ) @@ -830,25 +832,27 @@ class ConsoleConnectionTable(BaseTable): verbose_name='Console Port' ) connection_status = tables.TemplateColumn( + accessor=Accessor('path__is_connected'), + orderable=False, template_code=CONNECTION_STATUS, verbose_name='Status' ) class Meta(BaseTable.Meta): model = ConsolePort - fields = ('console_server', 'connected_endpoint', 'device', 'name', 'connection_status') + fields = ('console_server', 'console_server_port', 'device', 'name', 'connection_status') class PowerConnectionTable(BaseTable): - pdu = tables.LinkColumn( - viewname='dcim:device', - accessor=Accessor('connected_endpoint__device'), - args=[Accessor('connected_endpoint__device__pk')], - order_by='_connected_poweroutlet__device', + pdu = tables.Column( + accessor=Accessor('path__destination__device'), + orderable=False, + linkify=True, verbose_name='PDU' ) outlet = tables.Column( - accessor=Accessor('_connected_poweroutlet'), + accessor=Accessor('path__destination'), + orderable=False, linkify=True, verbose_name='Outlet' ) @@ -860,6 +864,8 @@ class PowerConnectionTable(BaseTable): verbose_name='Power Port' ) connection_status = tables.TemplateColumn( + accessor=Accessor('path__is_connected'), + orderable=False, template_code=CONNECTION_STATUS, verbose_name='Status' ) @@ -870,31 +876,31 @@ class PowerConnectionTable(BaseTable): class InterfaceConnectionTable(BaseTable): - device_a = tables.LinkColumn( - viewname='dcim:device', + device_a = tables.Column( accessor=Accessor('device'), - args=[Accessor('device__pk')], + linkify=True, verbose_name='Device A' ) - interface_a = tables.LinkColumn( - viewname='dcim:interface', + interface_a = tables.Column( accessor=Accessor('name'), - args=[Accessor('pk')], + linkify=True, verbose_name='Interface A' ) - device_b = tables.LinkColumn( - viewname='dcim:device', - accessor=Accessor('_connected_interface__device'), - args=[Accessor('_connected_interface__device__pk')], + device_b = tables.Column( + accessor=Accessor('path__destination__device'), + orderable=False, + linkify=True, verbose_name='Device B' ) - interface_b = tables.LinkColumn( - viewname='dcim:interface', - accessor=Accessor('_connected_interface'), - args=[Accessor('_connected_interface__pk')], + interface_b = tables.Column( + accessor=Accessor('path__destination'), + orderable=False, + linkify=True, verbose_name='Interface B' ) connection_status = tables.TemplateColumn( + accessor=Accessor('path__is_connected'), + orderable=False, template_code=CONNECTION_STATUS, verbose_name='Status' ) From 5737f6fca0b443d47087469519773d2519b83b1d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 17:16:43 -0400 Subject: [PATCH 089/291] Cache each CablePath on its originating endpoint --- netbox/circuits/migrations/0021_cablepath.py | 20 +++ netbox/dcim/migrations/0120_cablepath.py | 32 ++++- netbox/dcim/models/device_components.py | 26 ++-- netbox/dcim/models/devices.py | 7 + netbox/dcim/signals.py | 31 ++--- netbox/dcim/tests/test_cablepaths.py | 130 ++++++++++++++++--- 6 files changed, 191 insertions(+), 55 deletions(-) create mode 100644 netbox/circuits/migrations/0021_cablepath.py diff --git a/netbox/circuits/migrations/0021_cablepath.py b/netbox/circuits/migrations/0021_cablepath.py new file mode 100644 index 000000000..fabf71798 --- /dev/null +++ b/netbox/circuits/migrations/0021_cablepath.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-10-02 19:43 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0120_cablepath'), + ('circuits', '0020_custom_field_data'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + ] diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py index f3448e747..0d2199e31 100644 --- a/netbox/dcim/migrations/0120_cablepath.py +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2020-10-02 15:49 +# Generated by Django 3.1 on 2020-10-02 19:43 import dcim.fields from django.db import migrations, models @@ -28,4 +28,34 @@ class Migration(migrations.Migration): 'unique_together': {('origin_type', 'origin_id')}, }, ), + migrations.AddField( + model_name='consoleport', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='consoleserverport', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='interface', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='powerfeed', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='poweroutlet', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), + migrations.AddField( + model_name='powerport', + name='_path', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), + ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index b714c662a..65032f529 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -126,38 +126,30 @@ class CableTermination(models.Model): class PathEndpoint(models.Model): """ - Any object which may serve as either endpoint of a CablePath. + Any object which may serve as the originating endpoint of a CablePath. """ - _paths = GenericRelation( + _path = models.ForeignKey( to='dcim.CablePath', - content_type_field='origin_type', - object_id_field='origin_id', - related_query_name='%(class)s' + on_delete=models.SET_NULL, + null=True, + blank=True ) class Meta: abstract = True def trace(self): - if self.path is None: + if self._path is None: return [] # Construct the complete path - path = [self, *[path_node_to_object(obj) for obj in self.path.path], self.path.destination] - assert not len(path) % 3, f"Invalid path length for CablePath #{self.pk}: {len(self.path)} elements in path" + path = [self, *[path_node_to_object(obj) for obj in self._path.path], self._path.destination] + assert not len(path) % 3,\ + f"Invalid path length for CablePath #{self.pk}: {len(self._path.path)} elements in path" # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) - @property - def path(self): - """ - Return the _complete_ CablePath associated with this origin point, if any. - """ - if not hasattr(self, '_path'): - self._path = self._paths.filter(destination_id__isnull=False).first() - return self._path - # # Console ports diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 52627dc7d..ea7332b3d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1204,6 +1204,13 @@ class CablePath(models.Model): path = ', '.join([str(path_node_to_object(node)) for node in self.path]) return f"Path #{self.pk}: {self.origin} to {self.destination} via ({path})" + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + # Record a direct reference to this CablePath on its originating object + model = self.origin._meta.model + model.objects.filter(pk=self.origin.pk).update(_path=self.pk) + # # Virtual chassis diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 9b0493e34..13653c7d4 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,7 +1,7 @@ import logging from django.contrib.contenttypes.models import ContentType -from django.db.models.signals import post_save, pre_delete +from django.db.models.signals import post_save, post_delete, pre_delete from django.db import transaction from django.dispatch import receiver @@ -91,7 +91,7 @@ def update_connected_endpoints(instance, created, **kwargs): rebuild_paths(instance) -@receiver(pre_delete, sender=Cable) +@receiver(post_delete, sender=Cable) def nullify_connected_endpoints(instance, **kwargs): """ When a Cable is deleted, check for and update its two connected endpoints @@ -108,18 +108,15 @@ def nullify_connected_endpoints(instance, **kwargs): instance.termination_b.cable = None instance.termination_b.save() - # Delete any dependent cable paths - cable_paths = CablePath.objects.filter(path__contains=[object_to_path_node(instance)]) - retrace_queue = [cp.origin for cp in cable_paths] - deleted, _ = cable_paths.delete() - logger.info(f'Deleted {deleted} cable paths') - - # Retrace cable paths from the origins of deleted paths - for origin in retrace_queue: - # Delete and recreate all CablePaths for this origin point - # TODO: We can probably be smarter about skipping unchanged paths - CablePath.objects.filter( - origin_type=ContentType.objects.get_for_model(origin), - origin_id=origin.pk - ).delete() - create_cablepath(origin) + # Delete and retrace any dependent cable paths + for cablepath in CablePath.objects.filter(path__contains=[object_to_path_node(instance)]): + path, destination, is_connected = trace_path(cablepath.origin) + if path: + CablePath.objects.filter(pk=cablepath.pk).update( + path=path, + destination_type=ContentType.objects.get_for_model(destination) if destination else None, + destination_id=destination.pk if destination else None, + is_connected=is_connected + ) + else: + cablepath.delete() diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 18c15fd16..52b37e3bd 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -132,6 +132,8 @@ class CablePathTestCase(TestCase): :param path: Sequence of objects comprising the intermediate path (optional) :param is_connected: Boolean indicating whether the end-to-end path is complete and active (optional) :param msg: Custom failure message (optional) + + :return: The matching CablePath (if any) """ kwargs = { 'origin_type': ContentType.objects.get_for_model(origin), @@ -152,7 +154,34 @@ class CablePathTestCase(TestCase): msg = f"Missing path from {origin} to {destination}" else: msg = f"Missing partial path originating from {origin}" - self.assertEqual(CablePath.objects.filter(**kwargs).count(), 1, msg=msg) + + cablepath = CablePath.objects.filter(**kwargs).first() + self.assertIsNotNone(cablepath, msg=msg) + + return cablepath + + def assertPathIsSet(self, origin, cablepath, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param cablepath: The CablePath instance originating from this endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}" + self.assertEqual(origin._path_id, cablepath.pk, msg=msg) + + def assertPathIsNotSet(self, origin, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!" + self.assertIsNone(origin._path_id, msg=msg) def test_101_interface_to_interface(self): """ @@ -161,19 +190,23 @@ class CablePathTestCase(TestCase): # Create cable 1 cable1 = Cable(termination_a=self.interface1, termination_b=self.interface2) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=self.interface2, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.interface2, destination=self.interface1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsSet(self.interface2, path2) # Delete cable 1 cable1.delete() @@ -181,26 +214,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_103_consoleport_to_consoleserverport(self): + def test_102_consoleport_to_consoleserverport(self): """ [CP1] --C1-- [CSP1] """ # Create cable 1 cable1 = Cable(termination_a=self.consoleport1, termination_b=self.consoleserverport1) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.consoleport1, destination=self.consoleserverport1, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.consoleserverport1, destination=self.consoleport1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.consoleport1.refresh_from_db() + self.consoleserverport1.refresh_from_db() + self.assertPathIsSet(self.consoleport1, path1) + self.assertPathIsSet(self.consoleserverport1, path2) # Delete cable 1 cable1.delete() @@ -208,26 +245,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_104_powerport_to_poweroutlet(self): + def test_103_powerport_to_poweroutlet(self): """ [PP1] --C1-- [PO1] """ # Create cable 1 cable1 = Cable(termination_a=self.powerport1, termination_b=self.poweroutlet1) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.powerport1, destination=self.poweroutlet1, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.poweroutlet1, destination=self.powerport1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.powerport1.refresh_from_db() + self.poweroutlet1.refresh_from_db() + self.assertPathIsSet(self.powerport1, path1) + self.assertPathIsSet(self.poweroutlet1, path2) # Delete cable 1 cable1.delete() @@ -235,26 +276,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_105_powerport_to_powerfeed(self): + def test_104_powerport_to_powerfeed(self): """ [PP1] --C1-- [PF1] """ # Create cable 1 cable1 = Cable(termination_a=self.powerport1, termination_b=self.powerfeed1) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.powerport1, destination=self.powerfeed1, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.powerfeed1, destination=self.powerport1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.powerport1.refresh_from_db() + self.powerfeed1.refresh_from_db() + self.assertPathIsSet(self.powerport1, path1) + self.assertPathIsSet(self.powerfeed1, path2) # Delete cable 1 cable1.delete() @@ -262,26 +307,30 @@ class CablePathTestCase(TestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_106_interface_to_circuittermination(self): + def test_105_interface_to_circuittermination(self): """ [PP1] --C1-- [CT1A] """ # Create cable 1 cable1 = Cable(termination_a=self.interface1, termination_b=self.circuittermination1_A) cable1.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=self.circuittermination1_A, path=(cable1,), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.circuittermination1_A, destination=self.interface1, path=(cable1,), is_connected=True ) self.assertEqual(CablePath.objects.count(), 2) + self.interface1.refresh_from_db() + self.circuittermination1_A.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsSet(self.circuittermination1_A, path2) # Delete cable 1 cable1.delete() @@ -293,6 +342,9 @@ class CablePathTestCase(TestCase): """ [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + # Create cable 1 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) cable1.save() @@ -323,19 +375,28 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=None, path=(cable1, self.front_port5_1, self.rear_port5), is_connected=False ) self.assertEqual(CablePath.objects.count(), 1) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsNotSet(self.interface2) def test_202_multiple_paths_via_pass_through(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-2 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() @@ -377,7 +438,7 @@ class CablePathTestCase(TestCase): cable4.save() cable5 = Cable(termination_a=self.front_port2_2, termination_b=self.interface4) cable5.save() - self.assertPathExists( + path1 = self.assertPathExists( origin=self.interface1, destination=self.interface3, path=( @@ -386,7 +447,7 @@ class CablePathTestCase(TestCase): ), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.interface2, destination=self.interface4, path=( @@ -395,7 +456,7 @@ class CablePathTestCase(TestCase): ), is_connected=True ) - self.assertPathExists( + path3 = self.assertPathExists( origin=self.interface3, destination=self.interface1, path=( @@ -404,7 +465,7 @@ class CablePathTestCase(TestCase): ), is_connected=True ) - self.assertPathExists( + path4 = self.assertPathExists( origin=self.interface4, destination=self.interface2, path=( @@ -421,12 +482,25 @@ class CablePathTestCase(TestCase): # Check for four partial paths; one from each interface self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + self.assertPathIsSet(self.interface1, path1) + self.assertPathIsSet(self.interface2, path2) + self.assertPathIsSet(self.interface3, path3) + self.assertPathIsSet(self.interface4, path4) def test_203_multiple_paths_via_nested_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2:1] [RP2] --C4-- [RP3] [FP3:1] --C5-- [RP4] [FP4:1] --C6-- [IF3] [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-2, 6-7 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() @@ -502,6 +576,11 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3] [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-3, 6-8 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) cable1.save() @@ -576,6 +655,11 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP5] [RP5] --C4-- [RP2] [FP2:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + self.interface4.refresh_from_db() + # Create cables 1-2, 5-6 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) # IF1 -> FP1:1 cable1.save() @@ -641,6 +725,9 @@ class CablePathTestCase(TestCase): """ [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + # Create cable 2 cable2 = Cable(termination_a=self.rear_port5, termination_b=self.rear_port6) cable2.save() @@ -684,6 +771,9 @@ class CablePathTestCase(TestCase): """ [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + # Create cables 1 and 2 cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) cable1.save() From f8800b8303a188c2a7c62ce8f4f38b5e0f99e397 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Oct 2020 21:39:55 -0400 Subject: [PATCH 090/291] Optimize console/power/interface connection lists --- netbox/dcim/tables.py | 57 ++++++++++++++++++++++--------------------- netbox/dcim/views.py | 42 ++++++++++++------------------- 2 files changed, 45 insertions(+), 54 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 7ab08eae4..57cd0b6eb 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -67,8 +67,8 @@ INTERFACE_TAGGED_VLANS = """ {% endfor %} """ -CONNECTION_STATUS = """ -{{ record.get_connection_status_display }} +PATH_STATUS = """ +{% if value %}Connected{% else %}Not Connected{% endif %} """ @@ -813,13 +813,13 @@ class CableTable(BaseTable): class ConsoleConnectionTable(BaseTable): console_server = tables.Column( - accessor=Accessor('path__destination__device'), + accessor=Accessor('_path__destination__device'), orderable=False, linkify=True, verbose_name='Console Server' ) console_server_port = tables.Column( - accessor=Accessor('path__destination'), + accessor=Accessor('_path__destination'), orderable=False, linkify=True, verbose_name='Port' @@ -831,27 +831,28 @@ class ConsoleConnectionTable(BaseTable): linkify=True, verbose_name='Console Port' ) - connection_status = tables.TemplateColumn( - accessor=Accessor('path__is_connected'), - orderable=False, - template_code=CONNECTION_STATUS, - verbose_name='Status' + path_status = tables.TemplateColumn( + accessor=Accessor('_path__is_connected'), + template_code=PATH_STATUS, + verbose_name='Path Status' ) + add_prefetch = False + class Meta(BaseTable.Meta): model = ConsolePort - fields = ('console_server', 'console_server_port', 'device', 'name', 'connection_status') + fields = ('console_server', 'console_server_port', 'device', 'name', 'path_status') class PowerConnectionTable(BaseTable): pdu = tables.Column( - accessor=Accessor('path__destination__device'), + accessor=Accessor('_path__destination__device'), orderable=False, linkify=True, verbose_name='PDU' ) outlet = tables.Column( - accessor=Accessor('path__destination'), + accessor=Accessor('_path__destination'), orderable=False, linkify=True, verbose_name='Outlet' @@ -863,16 +864,17 @@ class PowerConnectionTable(BaseTable): linkify=True, verbose_name='Power Port' ) - connection_status = tables.TemplateColumn( - accessor=Accessor('path__is_connected'), - orderable=False, - template_code=CONNECTION_STATUS, - verbose_name='Status' + path_status = tables.TemplateColumn( + accessor=Accessor('_path__is_connected'), + template_code=PATH_STATUS, + verbose_name='Path Status' ) + add_prefetch = False + class Meta(BaseTable.Meta): model = PowerPort - fields = ('pdu', 'outlet', 'device', 'name', 'connection_status') + fields = ('pdu', 'outlet', 'device', 'name', 'path_status') class InterfaceConnectionTable(BaseTable): @@ -887,29 +889,28 @@ class InterfaceConnectionTable(BaseTable): verbose_name='Interface A' ) device_b = tables.Column( - accessor=Accessor('path__destination__device'), + accessor=Accessor('_path__destination__device'), orderable=False, linkify=True, verbose_name='Device B' ) interface_b = tables.Column( - accessor=Accessor('path__destination'), + accessor=Accessor('_path__destination'), orderable=False, linkify=True, verbose_name='Interface B' ) - connection_status = tables.TemplateColumn( - accessor=Accessor('path__is_connected'), - orderable=False, - template_code=CONNECTION_STATUS, - verbose_name='Status' + path_status = tables.TemplateColumn( + accessor=Accessor('_path__is_connected'), + template_code=PATH_STATUS, + verbose_name='Path Status' ) + add_prefetch = False + class Meta(BaseTable.Meta): model = Interface - fields = ( - 'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status', - ) + fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'path_status') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ac30461b0..87bd38309 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2079,12 +2079,8 @@ class CableBulkDeleteView(BulkDeleteView): class ConsoleConnectionsListView(ObjectListView): queryset = ConsolePort.objects.prefetch_related( - 'device', 'connected_endpoint__device' - ).filter( - connected_endpoint__isnull=False - ).order_by( - 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' - ) + 'device', '_path__destination__device' + ).filter(_path__isnull=False).order_by('device') filterset = filters.ConsoleConnectionFilterSet filterset_form = forms.ConsoleConnectionFilterForm table = tables.ConsoleConnectionTable @@ -2097,11 +2093,11 @@ class ConsoleConnectionsListView(ObjectListView): ] for obj in self.queryset: csv = csv_format([ - obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, - obj.connected_endpoint.name if obj.connected_endpoint else None, + obj._path.destination.device.identifier if obj._path.destination else None, + obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - obj.get_connection_status_display(), + 'Connected' if obj._path.is_connected else 'Not Connected', ]) csv_data.append(csv) @@ -2110,12 +2106,8 @@ class ConsoleConnectionsListView(ObjectListView): class PowerConnectionsListView(ObjectListView): queryset = PowerPort.objects.prefetch_related( - 'device', '_connected_poweroutlet__device' - ).filter( - _connected_poweroutlet__isnull=False - ).order_by( - 'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name' - ) + 'device', '_path__destination__device' + ).filter(_path__isnull=False).order_by('device') filterset = filters.PowerConnectionFilterSet filterset_form = forms.PowerConnectionFilterForm table = tables.PowerConnectionTable @@ -2128,11 +2120,11 @@ class PowerConnectionsListView(ObjectListView): ] for obj in self.queryset: csv = csv_format([ - obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, - obj.connected_endpoint.name if obj.connected_endpoint else None, + obj._path.destination.device.identifier if obj._path.destination else None, + obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - obj.get_connection_status_display(), + 'Connected' if obj._path.is_connected else 'Not Connected', ]) csv_data.append(csv) @@ -2141,14 +2133,12 @@ class PowerConnectionsListView(ObjectListView): class InterfaceConnectionsListView(ObjectListView): queryset = Interface.objects.prefetch_related( - 'device', 'cable', '_connected_interface__device' + 'device', '_path__destination__device' ).filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair - _connected_interface__isnull=False, + _path__isnull=False, pk__lt=F('_connected_interface') - ).order_by( - 'device' - ) + ).order_by('device') filterset = filters.InterfaceConnectionFilterSet filterset_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable @@ -2163,11 +2153,11 @@ class InterfaceConnectionsListView(ObjectListView): ] for obj in self.queryset: csv = csv_format([ - obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, - obj.connected_endpoint.name if obj.connected_endpoint else None, + obj._path.destination.device.identifier if obj._path.destination else None, + obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - obj.get_connection_status_display(), + 'Connected' if obj._path.is_connected else 'Not Connected', ]) csv_data.append(csv) From 079c42291c132e46d61125eef46e90d59403b739 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 09:56:46 -0400 Subject: [PATCH 091/291] Remove legacy connected endpoint fields --- netbox/circuits/api/views.py | 6 +- .../0022_drop_connected_endpoint.py | 17 +++ netbox/circuits/models.py | 7 -- netbox/circuits/views.py | 4 +- netbox/dcim/api/serializers.py | 5 +- netbox/dcim/api/views.py | 38 ++---- netbox/dcim/filters.py | 81 ++++++------ .../0121_drop_connected_endpoint.py | 37 ++++++ netbox/dcim/models/device_components.py | 117 ++---------------- netbox/dcim/models/power.py | 7 -- netbox/dcim/views.py | 8 +- netbox/netbox/views.py | 14 +-- netbox/templates/dcim/interface.html | 112 +++++++++-------- 13 files changed, 190 insertions(+), 263 deletions(-) create mode 100644 netbox/circuits/migrations/0022_drop_connected_endpoint.py create mode 100644 netbox/dcim/migrations/0121_drop_connected_endpoint.py diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index cd73a614d..1c8ad69e4 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -46,9 +46,7 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related( - 'site', 'connected_endpoint__device' - )), + Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), 'type', 'tenant', 'provider', ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer @@ -61,7 +59,7 @@ class CircuitViewSet(CustomFieldModelViewSet): class CircuitTerminationViewSet(ModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', 'connected_endpoint__device', 'cable' + 'circuit', 'site', 'cable' ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet diff --git a/netbox/circuits/migrations/0022_drop_connected_endpoint.py b/netbox/circuits/migrations/0022_drop_connected_endpoint.py new file mode 100644 index 000000000..59647dd2b --- /dev/null +++ b/netbox/circuits/migrations/0022_drop_connected_endpoint.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-10-05 13:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0021_cablepath'), + ] + + operations = [ + migrations.RemoveField( + model_name='circuittermination', + name='connected_endpoint', + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 686ab9219..746a71b3c 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -248,13 +248,6 @@ class CircuitTermination(PathEndpoint, CableTermination): on_delete=models.PROTECT, related_name='circuit_terminations' ) - connected_endpoint = models.OneToOneField( - to='dcim.Interface', - on_delete=models.SET_NULL, - related_name='+', - blank=True, - null=True - ) connection_status = models.BooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True, diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e5da5100f..968c88777 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -131,7 +131,7 @@ class CircuitView(ObjectView): circuit = get_object_or_404(self.queryset, pk=pk) termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( - 'site__region', 'connected_endpoint__device' + 'site__region' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A ).first() @@ -139,7 +139,7 @@ class CircuitView(ObjectView): termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( - 'site__region', 'connected_endpoint__device' + 'site__region' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z ).first() diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8078f8819..0cf78fafc 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -34,8 +34,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): def get_connected_endpoint_type(self, obj): if obj.path is not None: - destination = obj.path.destination - return f'{destination._meta.app_label}.{destination._meta.model_name}' + return f'{obj.connected_endpoint._meta.app_label}.{obj.connected_endpoint._meta.model_name}' return None @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -44,7 +43,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): Return the appropriate serializer for the type of connected object. """ if obj.path is not None: - serializer = get_serializer_for_model(obj.path.destination, prefix='Nested') + serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested') context = {'request': self.context['request']} return serializer(obj.path.destination, context=context).data return None diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index edbdfb7be..6ed50324e 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -470,37 +470,31 @@ class DeviceViewSet(CustomFieldModelViewSet): # class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') + queryset = ConsolePort.objects.prefetch_related('device', '_path', 'cable', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsolePortFilterSet class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') + queryset = ConsoleServerPort.objects.prefetch_related('device', '_path', 'cable', 'tags') serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filters.ConsoleServerPortFilterSet class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related( - 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags' - ) + queryset = PowerPort.objects.prefetch_related('device', '_path', 'cable', 'tags') serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerPortFilterSet class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') + queryset = PowerOutlet.objects.prefetch_related('device', '_path', 'cable', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filters.PowerOutletFilterSet class InterfaceViewSet(PathEndpointMixin, ModelViewSet): - queryset = Interface.objects.prefetch_related( - 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags' - ).filter( - device__isnull=False - ) + queryset = Interface.objects.prefetch_related('device', '_path', 'cable', 'ip_addresses', 'tags') serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilterSet @@ -534,32 +528,26 @@ class InventoryItemViewSet(ModelViewSet): # class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = ConsolePort.objects.prefetch_related( - 'device', 'connected_endpoint__device' - ).filter( - connected_endpoint__isnull=False + queryset = ConsolePort.objects.prefetch_related('device', '_path').filter( + _path__destination_id__isnull=False ) serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsoleConnectionFilterSet class PowerConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = PowerPort.objects.prefetch_related( - 'device', 'connected_endpoint__device' - ).filter( - _connected_poweroutlet__isnull=False + queryset = PowerPort.objects.prefetch_related('device', '_path').filter( + _path__destination_id__isnull=False ) serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerConnectionFilterSet class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = Interface.objects.prefetch_related( - 'device', '_connected_interface__device' - ).filter( + queryset = Interface.objects.prefetch_related('device', '_path').filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair - _connected_interface__isnull=False, - pk__lt=F('_connected_interface') + _path__destination_id__isnull=False, + pk__lt=F('_path__destination_id') ) serializer_class = serializers.InterfaceConnectionSerializer filterset_class = filters.InterfaceConnectionFilterSet @@ -664,7 +652,7 @@ class ConnectedDeviceViewSet(ViewSet): device__name=peer_device_name, name=peer_interface_name ) - local_interface = peer_interface._connected_interface + local_interface = peer_interface.connected_endpoint if local_interface is None: return Response() diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index c76bd3b87..aa5c62552 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1171,18 +1171,19 @@ class ConsoleConnectionFilterSet(BaseFilterSet): model = ConsolePort fields = ['name', 'connection_status'] - def filter_site(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter(connected_endpoint__device__site__slug=value) - - def filter_device(self, queryset, name, value): - if not value: - return queryset - return queryset.filter( - Q(**{'{}__in'.format(name): value}) | - Q(**{'connected_endpoint__{}__in'.format(name): value}) - ) + # TODO: Fix filters + # def filter_site(self, queryset, name, value): + # if not value.strip(): + # return queryset + # return queryset.filter(connected_endpoint__device__site__slug=value) + # + # def filter_device(self, queryset, name, value): + # if not value: + # return queryset + # return queryset.filter( + # Q(**{'{}__in'.format(name): value}) | + # Q(**{'connected_endpoint__{}__in'.format(name): value}) + # ) class PowerConnectionFilterSet(BaseFilterSet): @@ -1202,18 +1203,19 @@ class PowerConnectionFilterSet(BaseFilterSet): model = PowerPort fields = ['name', 'connection_status'] - def filter_site(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter(_connected_poweroutlet__device__site__slug=value) - - def filter_device(self, queryset, name, value): - if not value: - return queryset - return queryset.filter( - Q(**{'{}__in'.format(name): value}) | - Q(**{'_connected_poweroutlet__{}__in'.format(name): value}) - ) + # TODO: Fix filters + # def filter_site(self, queryset, name, value): + # if not value.strip(): + # return queryset + # return queryset.filter(_connected_poweroutlet__device__site__slug=value) + # + # def filter_device(self, queryset, name, value): + # if not value: + # return queryset + # return queryset.filter( + # Q(**{'{}__in'.format(name): value}) | + # Q(**{'_connected_poweroutlet__{}__in'.format(name): value}) + # ) class InterfaceConnectionFilterSet(BaseFilterSet): @@ -1233,21 +1235,22 @@ class InterfaceConnectionFilterSet(BaseFilterSet): model = Interface fields = ['connection_status'] - def filter_site(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(device__site__slug=value) | - Q(_connected_interface__device__site__slug=value) - ) - - def filter_device(self, queryset, name, value): - if not value: - return queryset - return queryset.filter( - Q(**{'{}__in'.format(name): value}) | - Q(**{'_connected_interface__{}__in'.format(name): value}) - ) + # TODO: Fix filters + # def filter_site(self, queryset, name, value): + # if not value.strip(): + # return queryset + # return queryset.filter( + # Q(device__site__slug=value) | + # Q(_connected_interface__device__site__slug=value) + # ) + # + # def filter_device(self, queryset, name, value): + # if not value: + # return queryset + # return queryset.filter( + # Q(**{'{}__in'.format(name): value}) | + # Q(**{'_connected_interface__{}__in'.format(name): value}) + # ) class PowerPanelFilterSet(BaseFilterSet): diff --git a/netbox/dcim/migrations/0121_drop_connected_endpoint.py b/netbox/dcim/migrations/0121_drop_connected_endpoint.py new file mode 100644 index 000000000..f05cfdd1a --- /dev/null +++ b/netbox/dcim/migrations/0121_drop_connected_endpoint.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1 on 2020-10-05 13:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0120_cablepath'), + ] + + operations = [ + migrations.RemoveField( + model_name='consoleport', + name='connected_endpoint', + ), + migrations.RemoveField( + model_name='interface', + name='_connected_circuittermination', + ), + migrations.RemoveField( + model_name='interface', + name='_connected_interface', + ), + migrations.RemoveField( + model_name='powerfeed', + name='connected_endpoint', + ), + migrations.RemoveField( + model_name='powerport', + name='_connected_powerfeed', + ), + migrations.RemoveField( + model_name='powerport', + name='_connected_poweroutlet', + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 65032f529..72edfc46e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -150,6 +150,15 @@ class PathEndpoint(models.Model): # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) + @property + def connected_endpoint(self): + """ + Caching accessor for the attached CablePath's destination (if any) + """ + if not hasattr(self, '_connected_endpoint'): + self._connected_endpoint = self._path.destination if self._path else None + return self._connected_endpoint + # # Console ports @@ -166,13 +175,6 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text='Physical port type' ) - connected_endpoint = models.OneToOneField( - to='dcim.ConsoleServerPort', - on_delete=models.SET_NULL, - related_name='connected_endpoint', - blank=True, - null=True - ) connection_status = models.BooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True, @@ -267,20 +269,6 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): validators=[MinValueValidator(1)], help_text="Allocated power draw (watts)" ) - _connected_poweroutlet = models.OneToOneField( - to='dcim.PowerOutlet', - on_delete=models.SET_NULL, - related_name='connected_endpoint', - blank=True, - null=True - ) - _connected_powerfeed = models.OneToOneField( - to='dcim.PowerFeed', - on_delete=models.SET_NULL, - related_name='+', - blank=True, - null=True - ) connection_status = models.BooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True, @@ -308,43 +296,6 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): self.description, ) - @property - def connected_endpoint(self): - """ - Return the connected PowerOutlet, if it exists, or the connected PowerFeed, if it exists. We have to check for - ObjectDoesNotExist in case the referenced object has been deleted from the database. - """ - try: - if self._connected_poweroutlet: - return self._connected_poweroutlet - except ObjectDoesNotExist: - pass - try: - if self._connected_powerfeed: - return self._connected_powerfeed - except ObjectDoesNotExist: - pass - return None - - @connected_endpoint.setter - def connected_endpoint(self, value): - # TODO: Fix circular import - from . import PowerFeed - - if value is None: - self._connected_poweroutlet = None - self._connected_powerfeed = None - elif isinstance(value, PowerOutlet): - self._connected_poweroutlet = value - self._connected_powerfeed = None - elif isinstance(value, PowerFeed): - self._connected_poweroutlet = None - self._connected_powerfeed = value - else: - raise ValueError( - "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value)) - ) - def get_power_draw(self): """ Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort. @@ -497,20 +448,6 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): max_length=100, blank=True ) - _connected_interface = models.OneToOneField( - to='self', - on_delete=models.SET_NULL, - related_name='+', - blank=True, - null=True - ) - _connected_circuittermination = models.OneToOneField( - to='circuits.CircuitTermination', - on_delete=models.SET_NULL, - related_name='+', - blank=True, - null=True - ) connection_status = models.BooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True, @@ -631,42 +568,6 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): return super().save(*args, **kwargs) - @property - def connected_endpoint(self): - """ - Return the connected Interface, if it exists, or the connected CircuitTermination, if it exists. We have to - check for ObjectDoesNotExist in case the referenced object has been deleted from the database. - """ - try: - if self._connected_interface: - return self._connected_interface - except ObjectDoesNotExist: - pass - try: - if self._connected_circuittermination: - return self._connected_circuittermination - except ObjectDoesNotExist: - pass - return None - - @connected_endpoint.setter - def connected_endpoint(self, value): - from circuits.models import CircuitTermination - - if value is None: - self._connected_interface = None - self._connected_circuittermination = None - elif isinstance(value, Interface): - self._connected_interface = value - self._connected_circuittermination = None - elif isinstance(value, CircuitTermination): - self._connected_interface = None - self._connected_circuittermination = value - else: - raise ValueError( - "Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value)) - ) - @property def parent(self): return self.device diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index caa22e74a..ec1480a7e 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -88,13 +88,6 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo blank=True, null=True ) - connected_endpoint = models.OneToOneField( - to='dcim.PowerPort', - on_delete=models.SET_NULL, - related_name='+', - blank=True, - null=True - ) connection_status = models.BooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 87bd38309..7a1028dec 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1122,10 +1122,8 @@ class DeviceLLDPNeighborsView(ObjectView): def get(self, request, pk): device = get_object_or_404(self.queryset, pk=pk) - interfaces = device.vc_interfaces.restrict(request.user, 'view').exclude( + interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related('_path').exclude( type__in=NONCONNECTABLE_IFACE_TYPES - ).prefetch_related( - '_connected_interface__device' ) return render(request, 'dcim/device_lldp_neighbors.html', { @@ -1483,8 +1481,6 @@ class InterfaceView(ObjectView): return render(request, 'dcim/interface.html', { 'instance': interface, - 'connected_interface': interface._connected_interface, - 'connected_circuittermination': interface._connected_circuittermination, 'ipaddress_table': ipaddress_table, 'vlan_table': vlan_table, }) @@ -2137,7 +2133,7 @@ class InterfaceConnectionsListView(ObjectListView): ).filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair _path__isnull=False, - pk__lt=F('_connected_interface') + pk__lt=F('_path__destination_id') ).order_by('device') filterset = filters.InterfaceConnectionFilterSet filterset_form = forms.InterfaceConnectionFilterForm diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index b2bee0f96..161bfda74 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -190,15 +190,15 @@ class HomeView(View): def get(self, request): - connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').filter( - connected_endpoint__isnull=False + connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + _path__destination_id__isnull=False ) - connected_powerports = PowerPort.objects.restrict(request.user, 'view').filter( - _connected_poweroutlet__isnull=False + connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + _path__destination_id__isnull=False ) - connected_interfaces = Interface.objects.restrict(request.user, 'view').filter( - _connected_interface__isnull=False, - pk__lt=F('_connected_interface') + connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( + _path__destination_id__isnull=False, + pk__lt=F('_path__destination_id') ) # Report Results diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 7fcf6ab0a..12b9918e9 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -77,61 +77,63 @@
    {% if instance.cable %} - {% if connected_interface %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% elif connected_circuittermination %} - {% with ct=connected_circuittermination %} + {% if instance.connected_endpoint.device %} + {% with iface=instance.connected_endpoint %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endwith %} + {% elif instance.connected_endpoint.circuit %} + {% with ct=instance.connected_endpoint %} From df737371289928bbcbef4bfec3ede8335f0ec630 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 10:08:16 -0400 Subject: [PATCH 092/291] Remove legacy connection_status fields --- .../0022_drop_connected_endpoint.py | 6 ++++- netbox/circuits/models.py | 6 ----- netbox/dcim/api/nested_serializers.py | 18 +++++-------- netbox/dcim/api/serializers.py | 16 ++++++------ netbox/dcim/filters.py | 12 +++------ .../0121_drop_connected_endpoint.py | 26 ++++++++++++++++++- netbox/dcim/models/device_components.py | 25 ------------------ netbox/dcim/models/power.py | 5 ---- netbox/dcim/tests/test_api.py | 14 +++++----- netbox/dcim/utils.py | 2 +- netbox/dcim/views.py | 15 ++++------- 11 files changed, 61 insertions(+), 84 deletions(-) diff --git a/netbox/circuits/migrations/0022_drop_connected_endpoint.py b/netbox/circuits/migrations/0022_drop_connected_endpoint.py index 59647dd2b..e5540b44d 100644 --- a/netbox/circuits/migrations/0022_drop_connected_endpoint.py +++ b/netbox/circuits/migrations/0022_drop_connected_endpoint.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2020-10-05 13:56 +# Generated by Django 3.1 on 2020-10-05 14:07 from django.db import migrations @@ -14,4 +14,8 @@ class Migration(migrations.Migration): model_name='circuittermination', name='connected_endpoint', ), + migrations.RemoveField( + model_name='circuittermination', + name='connection_status', + ), ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 746a71b3c..725fe4b3f 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -2,7 +2,6 @@ from django.db import models from django.urls import reverse from taggit.managers import TaggableManager -from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField from dcim.models import CableTermination, PathEndpoint from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem @@ -248,11 +247,6 @@ class CircuitTermination(PathEndpoint, CableTermination): on_delete=models.PROTECT, related_name='circuit_terminations' ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True, - null=True - ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)' ) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 40b03ada6..159540ece 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,8 +1,7 @@ from rest_framework import serializers -from dcim.constants import CONNECTION_STATUS_CHOICES from dcim import models -from utilities.api import ChoiceField, WritableNestedSerializer +from utilities.api import WritableNestedSerializer __all__ = [ 'NestedCableSerializer', @@ -228,51 +227,46 @@ class NestedDeviceSerializer(WritableNestedSerializer): class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = models.ConsoleServerPort - fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + fields = ['id', 'url', 'device', 'name', 'cable'] class NestedConsolePortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer(read_only=True) - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = models.ConsolePort - fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + fields = ['id', 'url', 'device', 'name', 'cable'] class NestedPowerOutletSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer(read_only=True) - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = models.PowerOutlet - fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + fields = ['id', 'url', 'device', 'name', 'cable'] class NestedPowerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer(read_only=True) - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = models.PowerPort - fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + fields = ['id', 'url', 'device', 'name', 'cable'] class NestedInterfaceSerializer(WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = models.Interface - fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + fields = ['id', 'url', 'device', 'name', 'cable'] class NestedRearPortSerializer(WritableNestedSerializer): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0cf78fafc..b591ae36a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -33,8 +33,8 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): connection_status = serializers.SerializerMethodField(read_only=True) def get_connected_endpoint_type(self, obj): - if obj.path is not None: - return f'{obj.connected_endpoint._meta.app_label}.{obj.connected_endpoint._meta.model_name}' + if obj._path is not None and obj._path.destination is not None: + return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}' return None @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -42,17 +42,17 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): """ Return the appropriate serializer for the type of connected object. """ - if obj.path is not None: - serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested') + if obj._path is not None and obj._path.destination is not None: + serializer = get_serializer_for_model(obj._path.destination, prefix='Nested') context = {'request': self.context['request']} - return serializer(obj.path.destination, context=context).data + return serializer(obj._path.destination, context=context).data return None # TODO: Tweak the representation for this field @swagger_serializer_method(serializer_or_field=serializers.BooleanField) def get_connection_status(self, obj): - if obj.path is not None: - return obj.path.is_connected + if obj._path is not None: + return obj._path.is_connected return None @@ -716,7 +716,7 @@ class TracedCableSerializer(serializers.ModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer): interface_a = serializers.SerializerMethodField() interface_b = NestedInterfaceSerializer(source='connected_endpoint') - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) + # connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) class Meta: model = Interface diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index aa5c62552..d16f5d1aa 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -760,11 +760,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): ) def filter_is_connected(self, queryset, name, value): - kwargs = {'connected_paths': 1 if value else 0} - # TODO: Boolean rather than Count()? - return queryset.annotate( - connected_paths=Count('_paths', filter=Q(_paths__is_connected=True)) - ).filter(**kwargs) + return queryset.filter(_path__is_connected=True) class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): @@ -1169,7 +1165,7 @@ class ConsoleConnectionFilterSet(BaseFilterSet): class Meta: model = ConsolePort - fields = ['name', 'connection_status'] + fields = ['name'] # TODO: Fix filters # def filter_site(self, queryset, name, value): @@ -1201,7 +1197,7 @@ class PowerConnectionFilterSet(BaseFilterSet): class Meta: model = PowerPort - fields = ['name', 'connection_status'] + fields = ['name'] # TODO: Fix filters # def filter_site(self, queryset, name, value): @@ -1233,7 +1229,7 @@ class InterfaceConnectionFilterSet(BaseFilterSet): class Meta: model = Interface - fields = ['connection_status'] + fields = [] # TODO: Fix filters # def filter_site(self, queryset, name, value): diff --git a/netbox/dcim/migrations/0121_drop_connected_endpoint.py b/netbox/dcim/migrations/0121_drop_connected_endpoint.py index f05cfdd1a..9404ecf53 100644 --- a/netbox/dcim/migrations/0121_drop_connected_endpoint.py +++ b/netbox/dcim/migrations/0121_drop_connected_endpoint.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2020-10-05 13:56 +# Generated by Django 3.1 on 2020-10-05 14:07 from django.db import migrations @@ -14,6 +14,14 @@ class Migration(migrations.Migration): model_name='consoleport', name='connected_endpoint', ), + migrations.RemoveField( + model_name='consoleport', + name='connection_status', + ), + migrations.RemoveField( + model_name='consoleserverport', + name='connection_status', + ), migrations.RemoveField( model_name='interface', name='_connected_circuittermination', @@ -22,10 +30,22 @@ class Migration(migrations.Migration): model_name='interface', name='_connected_interface', ), + migrations.RemoveField( + model_name='interface', + name='connection_status', + ), migrations.RemoveField( model_name='powerfeed', name='connected_endpoint', ), + migrations.RemoveField( + model_name='powerfeed', + name='connection_status', + ), + migrations.RemoveField( + model_name='poweroutlet', + name='connection_status', + ), migrations.RemoveField( model_name='powerport', name='_connected_powerfeed', @@ -34,4 +54,8 @@ class Migration(migrations.Migration): model_name='powerport', name='_connected_poweroutlet', ), + migrations.RemoveField( + model_name='powerport', + name='connection_status', + ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 72edfc46e..863b39e62 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -175,11 +175,6 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text='Physical port type' ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True, - null=True - ) tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'type', 'description'] @@ -216,11 +211,6 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text='Physical port type' ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True, - null=True - ) tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'type', 'description'] @@ -269,11 +259,6 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): validators=[MinValueValidator(1)], help_text="Allocated power draw (watts)" ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True, - null=True - ) tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] @@ -368,11 +353,6 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text="Phase (for three-phase feeds)" ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True, - null=True - ) tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] @@ -448,11 +428,6 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): max_length=100, blank=True ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True, - null=True - ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index ec1480a7e..f869a3af4 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -88,11 +88,6 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo blank=True, null=True ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True, - null=True - ) name = models.CharField( max_length=50 ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 528301f8f..5c13b5122 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -977,7 +977,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort - brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + brief_fields = ['cable', 'device', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1016,7 +1016,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort - brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + brief_fields = ['cable', 'device', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1055,7 +1055,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort - brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + brief_fields = ['cable', 'device', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1094,7 +1094,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet - brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + brief_fields = ['cable', 'device', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1133,7 +1133,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface - brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + brief_fields = ['cable', 'device', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1189,7 +1189,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ] -class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] bulk_update_data = { @@ -1247,7 +1247,7 @@ class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ] -class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class RearPortTest(APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] bulk_update_data = { diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 4ef902dc7..40896f97b 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -26,7 +26,7 @@ def trace_path(node): position_stack = [] is_connected = True - if node.cable is None: + if node is None or node.cable is None: return [], None, False while node.cable is not None: diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7a1028dec..9290a2b4a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1018,36 +1018,31 @@ class DeviceView(ObjectView): # Console ports consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), - 'cable', + 'cable', '_path', ) # Console server ports consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( device=device ).prefetch_related( - Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), - 'cable', + 'cable', '_path', ) # Power ports powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), - 'cable', + 'cable', '_path', ) # Power outlets poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), - 'cable', 'power_port', + 'cable', 'power_port', '_path', ) # Interfaces interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related( - Prefetch('_paths', queryset=CablePath.objects.filter(destination_id__isnull=False)), Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), - 'lag', 'cable', 'tags', + 'lag', 'cable', '_path', 'tags', ) # Front ports From 13db22d39264d91982a6ba758e995e9fe53d3830 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 11:07:03 -0400 Subject: [PATCH 093/291] Initial changelog notes for #4900 --- docs/release-notes/version-2.10.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index ef693a9de..c49283d14 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -34,6 +34,12 @@ http://netbox/api/dcim/sites/ \ --data '[{"id": 10, "description": "Foo"}, {"id": 11, "description": "Bar"}]' ``` +#### Improved Cable Trace Performance ([#4900](https://github.com/netbox-community/netbox/issues/4900)) + +All end-to-end cable paths are now cached using the new CablePath model. This allows NetBox to now immediately return the complete path originating from any endpoint directly from the database, rather than having to trace each cable recursively. It also resolves some systemic validation issues with the original implementation. + +**Note:** As part of this change, cable traces will no longer traverse circuits: A circuit termination will be considered the origin or destination of an end-to-end path. + ### Enhancements * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines @@ -56,9 +62,11 @@ http://netbox/api/dcim/sites/ \ * Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints * dcim.Cable: Added `custom_fields` +* dcim.FrontPort: Removed the `trace` endpoint * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning * dcim.PowerPanel: Added `custom_fields` * dcim.RackReservation: Added `custom_fields` +* dcim.RearPort: Removed the `trace` endpoint * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) From 3d34f1cdcb55d82bb484c5141df5788f4afa0c6a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 11:13:33 -0400 Subject: [PATCH 094/291] Rename CablePath.is_connected to is_active --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/filters.py | 2 +- netbox/dcim/migrations/0120_cablepath.py | 2 +- netbox/dcim/models/devices.py | 2 +- netbox/dcim/signals.py | 10 +-- netbox/dcim/tables.py | 6 +- netbox/dcim/tests/test_cablepaths.py | 92 ++++++++++++------------ netbox/dcim/utils.py | 8 +-- netbox/dcim/views.py | 6 +- 9 files changed, 65 insertions(+), 65 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b591ae36a..144e764dc 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -52,7 +52,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): @swagger_serializer_method(serializer_or_field=serializers.BooleanField) def get_connection_status(self, obj): if obj._path is not None: - return obj._path.is_connected + return obj._path.is_active return None diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d16f5d1aa..74b5903a6 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -760,7 +760,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): ) def filter_is_connected(self, queryset, name, value): - return queryset.filter(_path__is_connected=True) + return queryset.filter(_path__is_active=True) class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py index 0d2199e31..2b58f119a 100644 --- a/netbox/dcim/migrations/0120_cablepath.py +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): ('origin_id', models.PositiveIntegerField()), ('destination_id', models.PositiveIntegerField(blank=True, null=True)), ('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)), - ('is_connected', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), ('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), ('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), ], diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ea7332b3d..1f9db0986 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1191,7 +1191,7 @@ class CablePath(models.Model): fk_field='destination_id' ) path = PathField() - is_connected = models.BooleanField( + is_active = models.BooleanField( default=False ) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 13653c7d4..6079417c1 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -14,9 +14,9 @@ def create_cablepath(node): """ Create CablePaths for all paths originating from the specified node. """ - path, destination, is_connected = trace_path(node) + path, destination, is_active = trace_path(node) if path: - cp = CablePath(origin=node, path=path, destination=destination, is_connected=is_connected) + cp = CablePath(origin=node, path=path, destination=destination, is_active=is_active) cp.save() @@ -86,7 +86,7 @@ def update_connected_endpoints(instance, created, **kwargs): # may change in the future.) However, we do need to capture status changes and update # any CablePaths accordingly. if instance.status != CableStatusChoices.STATUS_CONNECTED: - CablePath.objects.filter(path__contains=[object_to_path_node(instance)]).update(is_connected=False) + CablePath.objects.filter(path__contains=[object_to_path_node(instance)]).update(is_active=False) else: rebuild_paths(instance) @@ -110,13 +110,13 @@ def nullify_connected_endpoints(instance, **kwargs): # Delete and retrace any dependent cable paths for cablepath in CablePath.objects.filter(path__contains=[object_to_path_node(instance)]): - path, destination, is_connected = trace_path(cablepath.origin) + path, destination, is_active = trace_path(cablepath.origin) if path: CablePath.objects.filter(pk=cablepath.pk).update( path=path, destination_type=ContentType.objects.get_for_model(destination) if destination else None, destination_id=destination.pk if destination else None, - is_connected=is_connected + is_active=is_active ) else: cablepath.delete() diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 57cd0b6eb..1ae5282f3 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -832,7 +832,7 @@ class ConsoleConnectionTable(BaseTable): verbose_name='Console Port' ) path_status = tables.TemplateColumn( - accessor=Accessor('_path__is_connected'), + accessor=Accessor('_path__is_active'), template_code=PATH_STATUS, verbose_name='Path Status' ) @@ -865,7 +865,7 @@ class PowerConnectionTable(BaseTable): verbose_name='Power Port' ) path_status = tables.TemplateColumn( - accessor=Accessor('_path__is_connected'), + accessor=Accessor('_path__is_active'), template_code=PATH_STATUS, verbose_name='Path Status' ) @@ -901,7 +901,7 @@ class InterfaceConnectionTable(BaseTable): verbose_name='Interface B' ) path_status = tables.TemplateColumn( - accessor=Accessor('_path__is_connected'), + accessor=Accessor('_path__is_active'), template_code=PATH_STATUS, verbose_name='Path Status' ) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 52b37e3bd..65de412cf 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -123,14 +123,14 @@ class CablePathTestCase(TestCase): cls.circuittermination2_Z, ]) - def assertPathExists(self, origin, destination, path=None, is_connected=None, msg=None): + def assertPathExists(self, origin, destination, path=None, is_active=None, msg=None): """ Assert that a CablePath from origin to destination with a specific intermediate path exists. :param origin: Originating endpoint :param destination: Terminating endpoint, or None :param path: Sequence of objects comprising the intermediate path (optional) - :param is_connected: Boolean indicating whether the end-to-end path is complete and active (optional) + :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) :param msg: Custom failure message (optional) :return: The matching CablePath (if any) @@ -147,8 +147,8 @@ class CablePathTestCase(TestCase): kwargs['destination_id__isnull'] = True if path is not None: kwargs['path'] = objects_to_path(*path) - if is_connected is not None: - kwargs['is_connected'] = is_connected + if is_active is not None: + kwargs['is_active'] = is_active if msg is None: if destination is not None: msg = f"Missing path from {origin} to {destination}" @@ -194,13 +194,13 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=self.interface2, path=(cable1,), - is_connected=True + is_active=True ) path2 = self.assertPathExists( origin=self.interface2, destination=self.interface1, path=(cable1,), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) self.interface1.refresh_from_db() @@ -225,13 +225,13 @@ class CablePathTestCase(TestCase): origin=self.consoleport1, destination=self.consoleserverport1, path=(cable1,), - is_connected=True + is_active=True ) path2 = self.assertPathExists( origin=self.consoleserverport1, destination=self.consoleport1, path=(cable1,), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) self.consoleport1.refresh_from_db() @@ -256,13 +256,13 @@ class CablePathTestCase(TestCase): origin=self.powerport1, destination=self.poweroutlet1, path=(cable1,), - is_connected=True + is_active=True ) path2 = self.assertPathExists( origin=self.poweroutlet1, destination=self.powerport1, path=(cable1,), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) self.powerport1.refresh_from_db() @@ -287,13 +287,13 @@ class CablePathTestCase(TestCase): origin=self.powerport1, destination=self.powerfeed1, path=(cable1,), - is_connected=True + is_active=True ) path2 = self.assertPathExists( origin=self.powerfeed1, destination=self.powerport1, path=(cable1,), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) self.powerport1.refresh_from_db() @@ -318,13 +318,13 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=self.circuittermination1_A, path=(cable1,), - is_connected=True + is_active=True ) path2 = self.assertPathExists( origin=self.circuittermination1_A, destination=self.interface1, path=(cable1,), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) self.interface1.refresh_from_db() @@ -352,7 +352,7 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=None, path=(cable1, self.front_port5_1, self.rear_port5), - is_connected=False + is_active=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -363,13 +363,13 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=self.interface2, path=(cable1, self.front_port5_1, self.rear_port5, cable2), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface2, destination=self.interface1, path=(cable2, self.rear_port5, self.front_port5_1, cable1), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -379,7 +379,7 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=None, path=(cable1, self.front_port5_1, self.rear_port5), - is_connected=False + is_active=False ) self.assertEqual(CablePath.objects.count(), 1) self.interface1.refresh_from_db() @@ -406,13 +406,13 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=None, path=(cable1, self.front_port1_1, self.rear_port1), - is_connected=False + is_active=False ) self.assertPathExists( origin=self.interface2, destination=None, path=(cable2, self.front_port1_2, self.rear_port1), - is_connected=False + is_active=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -423,13 +423,13 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=None, path=(cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1), - is_connected=False + is_active=False ) self.assertPathExists( origin=self.interface2, destination=None, path=(cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2), - is_connected=False + is_active=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -445,7 +445,7 @@ class CablePathTestCase(TestCase): cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1, cable4, ), - is_connected=True + is_active=True ) path2 = self.assertPathExists( origin=self.interface2, @@ -454,7 +454,7 @@ class CablePathTestCase(TestCase): cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2, cable5, ), - is_connected=True + is_active=True ) path3 = self.assertPathExists( origin=self.interface3, @@ -463,7 +463,7 @@ class CablePathTestCase(TestCase): cable4, self.front_port2_1, self.rear_port2, cable3, self.rear_port1, self.front_port1_1, cable1 ), - is_connected=True + is_active=True ) path4 = self.assertPathExists( origin=self.interface4, @@ -472,7 +472,7 @@ class CablePathTestCase(TestCase): cable5, self.front_port2_2, self.rear_port2, cable3, self.rear_port1, self.front_port1_2, cable2 ), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -530,7 +530,7 @@ class CablePathTestCase(TestCase): cable4, self.rear_port3, self.front_port3_1, cable5, self.rear_port4, self.front_port4_1, cable6 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface2, @@ -540,7 +540,7 @@ class CablePathTestCase(TestCase): cable4, self.rear_port3, self.front_port3_1, cable5, self.rear_port4, self.front_port4_2, cable7 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface3, @@ -550,7 +550,7 @@ class CablePathTestCase(TestCase): cable4, self.rear_port2, self.front_port2_1, cable3, self.rear_port1, self.front_port1_1, cable1 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface4, @@ -560,7 +560,7 @@ class CablePathTestCase(TestCase): cable4, self.rear_port2, self.front_port2_1, cable3, self.rear_port1, self.front_port1_2, cable2 ), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -609,7 +609,7 @@ class CablePathTestCase(TestCase): cable4, self.front_port3_1, self.rear_port3, cable6, self.rear_port4, self.front_port4_1, cable7 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface2, @@ -619,7 +619,7 @@ class CablePathTestCase(TestCase): cable5, self.front_port3_2, self.rear_port3, cable6, self.rear_port4, self.front_port4_2, cable8 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface3, @@ -629,7 +629,7 @@ class CablePathTestCase(TestCase): cable4, self.front_port2_1, self.rear_port2, cable3, self.rear_port1, self.front_port1_1, cable1 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface4, @@ -639,7 +639,7 @@ class CablePathTestCase(TestCase): cable5, self.front_port2_2, self.rear_port2, cable3, self.rear_port1, self.front_port1_2, cable2 ), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -683,7 +683,7 @@ class CablePathTestCase(TestCase): cable1, self.front_port1_1, self.rear_port1, cable3, self.front_port5_1, self.rear_port5, cable4, self.rear_port2, self.front_port2_1, cable5 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface2, @@ -692,7 +692,7 @@ class CablePathTestCase(TestCase): cable2, self.front_port1_2, self.rear_port1, cable3, self.front_port5_1, self.rear_port5, cable4, self.rear_port2, self.front_port2_2, cable6 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface3, @@ -701,7 +701,7 @@ class CablePathTestCase(TestCase): cable5, self.front_port2_1, self.rear_port2, cable4, self.rear_port5, self.front_port5_1, cable3, self.rear_port1, self.front_port1_1, cable1 ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface4, @@ -710,7 +710,7 @@ class CablePathTestCase(TestCase): cable6, self.front_port2_2, self.rear_port2, cable4, self.rear_port5, self.front_port5_1, cable3, self.rear_port1, self.front_port1_2, cable2 ), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -740,7 +740,7 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=None, path=(cable1, self.front_port5_1, self.rear_port5, cable2, self.rear_port6, self.front_port6_1), - is_connected=False + is_active=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -754,7 +754,7 @@ class CablePathTestCase(TestCase): cable1, self.front_port5_1, self.rear_port5, cable2, self.rear_port6, self.front_port6_1, cable3, ), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface2, @@ -763,7 +763,7 @@ class CablePathTestCase(TestCase): cable3, self.front_port6_1, self.rear_port6, cable2, self.rear_port5, self.front_port5_1, cable1, ), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -779,7 +779,7 @@ class CablePathTestCase(TestCase): cable1.save() cable2 = Cable(termination_a=self.rear_port5, termination_b=self.interface2) cable2.save() - self.assertEqual(CablePath.objects.filter(is_connected=True).count(), 2) + self.assertEqual(CablePath.objects.filter(is_active=True).count(), 2) self.assertEqual(CablePath.objects.count(), 2) # Change cable 2's status to "planned" @@ -789,13 +789,13 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=self.interface2, path=(cable1, self.front_port5_1, self.rear_port5, cable2), - is_connected=False + is_active=False ) self.assertPathExists( origin=self.interface2, destination=self.interface1, path=(cable2, self.rear_port5, self.front_port5_1, cable1), - is_connected=False + is_active=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -807,12 +807,12 @@ class CablePathTestCase(TestCase): origin=self.interface1, destination=self.interface2, path=(cable1, self.front_port5_1, self.rear_port5, cable2), - is_connected=True + is_active=True ) self.assertPathExists( origin=self.interface2, destination=self.interface1, path=(cable2, self.rear_port5, self.front_port5_1, cable1), - is_connected=True + is_active=True ) self.assertEqual(CablePath.objects.count(), 2) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 40896f97b..186ea72e5 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -24,14 +24,14 @@ def trace_path(node): destination = None path = [] position_stack = [] - is_connected = True + is_active = True if node is None or node.cable is None: return [], None, False while node.cable is not None: if node.cable.status != CableStatusChoices.STATUS_CONNECTED: - is_connected = False + is_active = False # Follow the cable to its far-end termination path.append(object_to_path_node(node.cable)) @@ -65,6 +65,6 @@ def trace_path(node): break if destination is None: - is_connected = False + is_active = False - return path, destination, is_connected + return path, destination, is_active diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9290a2b4a..a2850fc46 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2088,7 +2088,7 @@ class ConsoleConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Connected' if obj._path.is_connected else 'Not Connected', + 'Connected' if obj._path.is_active else 'Not Connected', ]) csv_data.append(csv) @@ -2115,7 +2115,7 @@ class PowerConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Connected' if obj._path.is_connected else 'Not Connected', + 'Connected' if obj._path.is_active else 'Not Connected', ]) csv_data.append(csv) @@ -2148,7 +2148,7 @@ class InterfaceConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Connected' if obj._path.is_connected else 'Not Connected', + 'Connected' if obj._path.is_active else 'Not Connected', ]) csv_data.append(csv) From b846f631a40d8d3162254ac883b2435270c56bb0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 11:32:39 -0400 Subject: [PATCH 095/291] Rename connection_status to connected_endpoint_reachable --- docs/release-notes/version-2.10.md | 6 ++++++ netbox/circuits/api/serializers.py | 2 +- netbox/dcim/api/serializers.py | 19 +++++++++---------- netbox/dcim/constants.py | 6 ------ netbox/dcim/tests/test_views.py | 5 ----- netbox/utilities/custom_inspectors.py | 4 ++-- 6 files changed, 18 insertions(+), 24 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index c49283d14..b43662a3e 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -61,10 +61,16 @@ All end-to-end cable paths are now cached using the new CablePath model. This al ### REST API Changes * Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints +* circuits.CircuitTermination: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * dcim.Cable: Added `custom_fields` +* dcim.ConsolePort: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) +* dcim.ConsoleServerPort: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * dcim.FrontPort: Removed the `trace` endpoint +* dcim.Interface: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning +* dcim.PowerOutlet: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * dcim.PowerPanel: Added `custom_fields` +* dcim.PowerPort: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * dcim.RackReservation: Added `custom_fields` * dcim.RearPort: Removed the `trace` endpoint * dcim.VirtualChassis: Added `custom_fields` diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 10ae5e5ee..03c9012af 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -77,5 +77,5 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer): model = CircuitTermination fields = [ 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 144e764dc..42018c046 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -30,7 +30,7 @@ from .nested_serializers import * class ConnectedEndpointSerializer(ValidatedModelSerializer): connected_endpoint_type = serializers.SerializerMethodField(read_only=True) connected_endpoint = serializers.SerializerMethodField(read_only=True) - connection_status = serializers.SerializerMethodField(read_only=True) + connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) def get_connected_endpoint_type(self, obj): if obj._path is not None and obj._path.destination is not None: @@ -48,9 +48,8 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): return serializer(obj._path.destination, context=context).data return None - # TODO: Tweak the representation for this field @swagger_serializer_method(serializer_or_field=serializers.BooleanField) - def get_connection_status(self, obj): + def get_connected_endpoint_reachable(self, obj): if obj._path is not None: return obj._path.is_active return None @@ -467,7 +466,7 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSeria model = ConsoleServerPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connection_status', 'cable', 'tags', + 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', ] @@ -485,7 +484,7 @@ class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer) model = ConsolePort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connection_status', 'cable', 'tags', + 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', ] @@ -513,7 +512,7 @@ class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer) model = PowerOutlet fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', + 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', ] @@ -531,7 +530,7 @@ class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): model = PowerPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', + 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', ] @@ -555,8 +554,8 @@ class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): model = Interface fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', + 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', + 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', ] # TODO: This validation should be handled by Interface.clean() @@ -720,7 +719,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): class Meta: model = Interface - fields = ['interface_a', 'interface_b', 'connection_status'] + fields = ['interface_a', 'interface_b'] @swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer) def get_interface_a(self, obj): diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 961c458e0..804e5be03 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -59,12 +59,6 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage # Cabling and connections # -# Console/power/interface connection statuses -CONNECTION_STATUS_CHOICES = [ - [False, 'Not Connected'], - [True, 'Connected'], -] - # Cable endpoint types CABLE_TERMINATION_MODELS = Q( Q(app_label='circuits', model__in=( diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 83d8841df..f3d942112 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1714,11 +1714,6 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'max_utilization': 50, 'comments': 'New comments', 'tags': [t.pk for t in tags], - - # Connection - 'cable': None, - 'connected_endpoint': None, - 'connection_status': None, } cls.csv_data = ( diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 1d5c9c0a0..063d30016 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -62,8 +62,8 @@ class ChoiceFieldInspector(FieldInspector): value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value) if set([None] + choice_value) == {None, True, False}: - # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be - # differentiated since they each have subtly different values in their choice keys. + # DeviceType.subdevice_role and Device.face need to be differentiated since they each have + # subtly different values in their choice keys. # - subdevice_role and connection_status are booleans, although subdevice_role includes None # - face is an integer set {0, 1} which is easily confused with {False, True} schema_type = openapi.TYPE_STRING From 32aa2daea634b7dfd901a27402655844f0666024 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 11:39:17 -0400 Subject: [PATCH 096/291] PowerFeedSerializer should subclass ConnectedEndpointSerializer --- docs/release-notes/version-2.10.md | 1 + netbox/dcim/api/serializers.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index b43662a3e..ee49f223f 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -68,6 +68,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * dcim.FrontPort: Removed the `trace` endpoint * dcim.Interface: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning +* dcim.PowerFeed: Add fields `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` * dcim.PowerOutlet: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * dcim.PowerPanel: Added `custom_fields` * dcim.PowerPort: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 42018c046..031ce575b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -760,7 +760,7 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class PowerFeedSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( @@ -790,5 +790,6 @@ class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): model = PowerFeed fields = [ 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'cable', + 'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', ] From b2066bc4b728e5d2a3b9103eb29aea911a096524 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 11:47:24 -0400 Subject: [PATCH 097/291] Merge schema migrations --- netbox/circuits/migrations/0021_cablepath.py | 8 +++ .../0022_drop_connected_endpoint.py | 21 ------- netbox/dcim/migrations/0120_cablepath.py | 48 +++++++++++++++ .../0121_drop_connected_endpoint.py | 61 ------------------- 4 files changed, 56 insertions(+), 82 deletions(-) delete mode 100644 netbox/circuits/migrations/0022_drop_connected_endpoint.py delete mode 100644 netbox/dcim/migrations/0121_drop_connected_endpoint.py diff --git a/netbox/circuits/migrations/0021_cablepath.py b/netbox/circuits/migrations/0021_cablepath.py index fabf71798..b416d2e9f 100644 --- a/netbox/circuits/migrations/0021_cablepath.py +++ b/netbox/circuits/migrations/0021_cablepath.py @@ -17,4 +17,12 @@ class Migration(migrations.Migration): name='_path', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), ), + migrations.RemoveField( + model_name='circuittermination', + name='connected_endpoint', + ), + migrations.RemoveField( + model_name='circuittermination', + name='connection_status', + ), ] diff --git a/netbox/circuits/migrations/0022_drop_connected_endpoint.py b/netbox/circuits/migrations/0022_drop_connected_endpoint.py deleted file mode 100644 index e5540b44d..000000000 --- a/netbox/circuits/migrations/0022_drop_connected_endpoint.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1 on 2020-10-05 14:07 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0021_cablepath'), - ] - - operations = [ - migrations.RemoveField( - model_name='circuittermination', - name='connected_endpoint', - ), - migrations.RemoveField( - model_name='circuittermination', - name='connection_status', - ), - ] diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0120_cablepath.py index 2b58f119a..dd3c4ed19 100644 --- a/netbox/dcim/migrations/0120_cablepath.py +++ b/netbox/dcim/migrations/0120_cablepath.py @@ -58,4 +58,52 @@ class Migration(migrations.Migration): name='_path', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'), ), + migrations.RemoveField( + model_name='consoleport', + name='connected_endpoint', + ), + migrations.RemoveField( + model_name='consoleport', + name='connection_status', + ), + migrations.RemoveField( + model_name='consoleserverport', + name='connection_status', + ), + migrations.RemoveField( + model_name='interface', + name='_connected_circuittermination', + ), + migrations.RemoveField( + model_name='interface', + name='_connected_interface', + ), + migrations.RemoveField( + model_name='interface', + name='connection_status', + ), + migrations.RemoveField( + model_name='powerfeed', + name='connected_endpoint', + ), + migrations.RemoveField( + model_name='powerfeed', + name='connection_status', + ), + migrations.RemoveField( + model_name='poweroutlet', + name='connection_status', + ), + migrations.RemoveField( + model_name='powerport', + name='_connected_powerfeed', + ), + migrations.RemoveField( + model_name='powerport', + name='_connected_poweroutlet', + ), + migrations.RemoveField( + model_name='powerport', + name='connection_status', + ), ] diff --git a/netbox/dcim/migrations/0121_drop_connected_endpoint.py b/netbox/dcim/migrations/0121_drop_connected_endpoint.py deleted file mode 100644 index 9404ecf53..000000000 --- a/netbox/dcim/migrations/0121_drop_connected_endpoint.py +++ /dev/null @@ -1,61 +0,0 @@ -# Generated by Django 3.1 on 2020-10-05 14:07 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0120_cablepath'), - ] - - operations = [ - migrations.RemoveField( - model_name='consoleport', - name='connected_endpoint', - ), - migrations.RemoveField( - model_name='consoleport', - name='connection_status', - ), - migrations.RemoveField( - model_name='consoleserverport', - name='connection_status', - ), - migrations.RemoveField( - model_name='interface', - name='_connected_circuittermination', - ), - migrations.RemoveField( - model_name='interface', - name='_connected_interface', - ), - migrations.RemoveField( - model_name='interface', - name='connection_status', - ), - migrations.RemoveField( - model_name='powerfeed', - name='connected_endpoint', - ), - migrations.RemoveField( - model_name='powerfeed', - name='connection_status', - ), - migrations.RemoveField( - model_name='poweroutlet', - name='connection_status', - ), - migrations.RemoveField( - model_name='powerport', - name='_connected_powerfeed', - ), - migrations.RemoveField( - model_name='powerport', - name='_connected_poweroutlet', - ), - migrations.RemoveField( - model_name='powerport', - name='connection_status', - ), - ] From 50aecd02f45236479751e915de89895d2e5c1d3b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 12:05:29 -0400 Subject: [PATCH 098/291] Fix up connection lists (pending additional work) --- netbox/dcim/filters.py | 64 +++++++++++------------------------------- netbox/dcim/tables.py | 25 ++++++----------- netbox/dcim/views.py | 6 ++-- 3 files changed, 28 insertions(+), 67 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 74b5903a6..aeccc341b 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1150,7 +1150,20 @@ class CableFilterSet(BaseFilterSet): return queryset -class ConsoleConnectionFilterSet(BaseFilterSet): +class ConnectionFilterSet: + + def filter_site(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(device__site__slug=value) + + def filter_device(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(device_id__in=value) + + +class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1167,22 +1180,8 @@ class ConsoleConnectionFilterSet(BaseFilterSet): model = ConsolePort fields = ['name'] - # TODO: Fix filters - # def filter_site(self, queryset, name, value): - # if not value.strip(): - # return queryset - # return queryset.filter(connected_endpoint__device__site__slug=value) - # - # def filter_device(self, queryset, name, value): - # if not value: - # return queryset - # return queryset.filter( - # Q(**{'{}__in'.format(name): value}) | - # Q(**{'connected_endpoint__{}__in'.format(name): value}) - # ) - -class PowerConnectionFilterSet(BaseFilterSet): +class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1199,22 +1198,8 @@ class PowerConnectionFilterSet(BaseFilterSet): model = PowerPort fields = ['name'] - # TODO: Fix filters - # def filter_site(self, queryset, name, value): - # if not value.strip(): - # return queryset - # return queryset.filter(_connected_poweroutlet__device__site__slug=value) - # - # def filter_device(self, queryset, name, value): - # if not value: - # return queryset - # return queryset.filter( - # Q(**{'{}__in'.format(name): value}) | - # Q(**{'_connected_poweroutlet__{}__in'.format(name): value}) - # ) - -class InterfaceConnectionFilterSet(BaseFilterSet): +class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1231,23 +1216,6 @@ class InterfaceConnectionFilterSet(BaseFilterSet): model = Interface fields = [] - # TODO: Fix filters - # def filter_site(self, queryset, name, value): - # if not value.strip(): - # return queryset - # return queryset.filter( - # Q(device__site__slug=value) | - # Q(_connected_interface__device__site__slug=value) - # ) - # - # def filter_device(self, queryset, name, value): - # if not value: - # return queryset - # return queryset.filter( - # Q(**{'{}__in'.format(name): value}) | - # Q(**{'_connected_interface__{}__in'.format(name): value}) - # ) - class PowerPanelFilterSet(BaseFilterSet): q = django_filters.CharFilter( diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1ae5282f3..b1aa6e57d 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -67,10 +67,6 @@ INTERFACE_TAGGED_VLANS = """ {% endfor %} """ -PATH_STATUS = """ -{% if value %}Connected{% else %}Not Connected{% endif %} -""" - # # Regions @@ -831,17 +827,16 @@ class ConsoleConnectionTable(BaseTable): linkify=True, verbose_name='Console Port' ) - path_status = tables.TemplateColumn( + reachable = BooleanColumn( accessor=Accessor('_path__is_active'), - template_code=PATH_STATUS, - verbose_name='Path Status' + verbose_name='Reachable' ) add_prefetch = False class Meta(BaseTable.Meta): model = ConsolePort - fields = ('console_server', 'console_server_port', 'device', 'name', 'path_status') + fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') class PowerConnectionTable(BaseTable): @@ -864,17 +859,16 @@ class PowerConnectionTable(BaseTable): linkify=True, verbose_name='Power Port' ) - path_status = tables.TemplateColumn( + reachable = BooleanColumn( accessor=Accessor('_path__is_active'), - template_code=PATH_STATUS, - verbose_name='Path Status' + verbose_name='Reachable' ) add_prefetch = False class Meta(BaseTable.Meta): model = PowerPort - fields = ('pdu', 'outlet', 'device', 'name', 'path_status') + fields = ('device', 'name', 'pdu', 'outlet', 'reachable') class InterfaceConnectionTable(BaseTable): @@ -900,17 +894,16 @@ class InterfaceConnectionTable(BaseTable): linkify=True, verbose_name='Interface B' ) - path_status = tables.TemplateColumn( + reachable = BooleanColumn( accessor=Accessor('_path__is_active'), - template_code=PATH_STATUS, - verbose_name='Path Status' + verbose_name='Reachable' ) add_prefetch = False class Meta(BaseTable.Meta): model = Interface - fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'path_status') + fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a2850fc46..d7ddc1855 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2088,7 +2088,7 @@ class ConsoleConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Connected' if obj._path.is_active else 'Not Connected', + 'Reachable' if obj._path.is_active else 'Not Reachable', ]) csv_data.append(csv) @@ -2115,7 +2115,7 @@ class PowerConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Connected' if obj._path.is_active else 'Not Connected', + 'Reachable' if obj._path.is_active else 'Not Reachable', ]) csv_data.append(csv) @@ -2148,7 +2148,7 @@ class InterfaceConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Connected' if obj._path.is_active else 'Not Connected', + 'Reachable' if obj._path.is_active else 'Not Reachable', ]) csv_data.append(csv) From 32b8148da1e9a5c0c606bb66f363ea26b0732e77 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 13:23:55 -0400 Subject: [PATCH 099/291] Standardize path endpoint templates --- netbox/dcim/models/device_components.py | 4 ++ netbox/dcim/urls.py | 1 + netbox/templates/dcim/consoleport.html | 38 +++++------ netbox/templates/dcim/consoleserverport.html | 38 +++++------ netbox/templates/dcim/interface.html | 26 ++++---- netbox/templates/dcim/powerfeed.html | 69 ++++++++++++++++++-- netbox/templates/dcim/poweroutlet.html | 38 +++++------ netbox/templates/dcim/powerport.html | 38 +++++------ 8 files changed, 158 insertions(+), 94 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 863b39e62..2ab3ce7c8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -150,6 +150,10 @@ class PathEndpoint(models.Model): # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) + @property + def path(self): + return self._path + @property def connected_endpoint(self): """ diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 90f6b5ef2..884941c9d 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -385,5 +385,6 @@ urlpatterns = [ path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), + path('power-feeds//connect//', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}), ] diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 3f4d7b306..2b79b2b1c 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -44,6 +44,15 @@ {% if instance.cable %}
    Device - {{ connected_interface.device }} -
    Name - {{ connected_interface.name }} -
    Type{{ connected_interface.get_type_display }}
    Enabled - {% if connected_interface.enabled %} - - {% else %} - - {% endif %} -
    LAG - {% if connected_interface.lag%} - {{ connected_interface.lag }} - {% else %} - None - {% endif %} -
    Description{{ connected_interface.description|placeholder }}
    MTU{{ connected_interface.mtu|placeholder }}
    MAC Address{{ connected_interface.mac_address|placeholder }}
    802.1Q Mode{{ connected_interface.get_mode_display }}
    Device + {{ iface.device }} +
    Name + {{ iface.name }} +
    Type{{ iface.get_type_display }}
    Enabled + {% if iface.enabled %} + + {% else %} + + {% endif %} +
    LAG + {% if iface.lag%} + {{ iface.lag }} + {% else %} + None + {% endif %} +
    Description{{ iface.description|placeholder }}
    MTU{{ iface.mtu|placeholder }}
    MAC Address{{ iface.mac_address|placeholder }}
    802.1Q Mode{{ iface.get_mode_display }}
    Provider {{ ct.circuit.provider }}
    + + + + {% if instance.connected_endpoint %} @@ -65,26 +74,17 @@ + + + + {% endif %} - - - - - - - -
    Cable + {{ instance.cable }} + + + +
    DeviceDescription {{ instance.connected_endpoint.description|placeholder }}
    Path Status + {% if instance.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    Cable - {{ instance.cable }} - - - -
    Connection Status - {% if instance.connection_status %} - {{ instance.get_connection_status_display }} - {% else %} - {{ instance.get_connection_status_display }} - {% endif %} -
    {% else %}
    diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 77d17fe8a..ae164634d 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -44,6 +44,15 @@
    {% if instance.cable %} + + + + {% if instance.connected_endpoint %} @@ -65,26 +74,17 @@ + + + + {% endif %} - - - - - - - -
    Cable + {{ instance.cable }} + + + +
    DeviceDescription {{ instance.connected_endpoint.description|placeholder }}
    Path Status + {% if instance.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    Cable - {{ instance.cable }} - - - -
    Connection Status - {% if instance.connection_status %} - {{ instance.get_connection_status_display }} - {% else %} - {{ instance.get_connection_status_display }} - {% endif %} -
    {% else %}
    diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 12b9918e9..2a3435f33 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -77,6 +77,15 @@
    {% if instance.cable %} + + + + {% if instance.connected_endpoint.device %} {% with iface=instance.connected_endpoint %} @@ -149,21 +158,12 @@ {% endwith %} {% endif %} - + - - - - diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 017a430ee..9f0a45fcd 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -123,11 +123,6 @@
    Cable + {{ instance.cable }} + + + +
    CablePath Status - {{ instance.cable }} - - - -
    Connection Status - {% if instance.connection_status %} - {{ instance.get_connection_status_display }} + {% if instance.path.is_active %} + Reachable {% else %} - {{ instance.get_connection_status_display }} + Not Reachable {% endif %}
    - {% include 'inc/custom_fields_panel.html' with obj=powerfeed %} - {% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %} - {% plugin_left_page powerfeed %} -
    -
    Electrical Characteristics @@ -155,6 +150,70 @@
    + {% include 'inc/custom_fields_panel.html' with obj=powerfeed %} + {% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %} + {% plugin_left_page powerfeed %} +
    +
    +
    +
    + Connection +
    + {% if powerfeed.cable %} + + + + + + {% if powerfeed.connected_endpoint %} + + + + + + + + + + + + + + + + + + + + + {% endif %} +
    Cable + {{ powerfeed.cable }} + + + +
    Device + {{ powerfeed.connected_endpoint.device }} +
    Name + {{ powerfeed.connected_endpoint.name }} +
    Type{{ powerfeed.connected_endpoint.get_type_display|placeholder }}
    Description{{ powerfeed.connected_endpoint.description|placeholder }}
    Path Status + {% if powerfeed.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    + {% else %} +
    + {% if perms.dcim.add_cable %} + + Connect + + {% endif %} + Not connected +
    + {% endif %} +
    Comments diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 2ea70972b..82782be09 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -52,6 +52,15 @@
    {% if instance.cable %} + + + + {% if instance.connected_endpoint %} @@ -73,26 +82,17 @@ + + + + {% endif %} - - - - - - - -
    Cable + {{ instance.cable }} + + + +
    DeviceDescription {{ instance.connected_endpoint.description|placeholder }}
    Path Status + {% if instance.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    Cable - {{ instance.cable }} - - - -
    Connection Status - {% if instance.connection_status %} - {{ instance.get_connection_status_display }} - {% else %} - {{ instance.get_connection_status_display }} - {% endif %} -
    {% else %}
    diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 3f3d28899..52c9c0e95 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -52,6 +52,15 @@
    {% if instance.cable %} + + + + {% if instance.connected_endpoint %} @@ -73,26 +82,17 @@ + + + + {% endif %} - - - - - - - -
    Cable + {{ instance.cable }} + + + +
    DeviceDescription {{ instance.connected_endpoint.description|placeholder }}
    Path Status + {% if instance.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    Cable - {{ instance.cable }} - - - -
    Connection Status - {% if instance.connection_status %} - {{ instance.get_connection_status_display }} - {% else %} - {{ instance.get_connection_status_display }} - {% endif %} -
    {% else %}
    From d5d6b0e856cc370ff58d33663174e0d9377cf933 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 14:47:21 -0400 Subject: [PATCH 100/291] Optimize path prefetching --- netbox/dcim/views.py | 10 +++++----- netbox/templates/dcim/device.html | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d7ddc1855..c82b9e6ac 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1018,31 +1018,31 @@ class DeviceView(ObjectView): # Console ports consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - 'cable', '_path', + 'cable', '_path__destination', ) # Console server ports consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( device=device ).prefetch_related( - 'cable', '_path', + 'cable', '_path__destination', ) # Power ports powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - 'cable', '_path', + 'cable', '_path__destination', ) # Power outlets poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( - 'cable', 'power_port', '_path', + 'cable', 'power_port', '_path__destination', ) # Interfaces interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), - 'lag', 'cable', '_path', 'tags', + 'lag', 'cable', '_path__destination', 'tags', ) # Front ports diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 96b61ea47..6c7b1c971 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -479,7 +479,7 @@
    -
    +
    {% csrf_token %}
    From 19430ddeb5ff02641e1c0f70e7630bb19cdd0c72 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Oct 2020 16:03:30 -0400 Subject: [PATCH 101/291] Extend cable trace view to show related paths --- netbox/dcim/views.py | 38 ++++++--- netbox/templates/dcim/cable_trace.html | 104 +++++++++++++++---------- 2 files changed, 90 insertions(+), 52 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c82b9e6ac..3608b3792 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -34,10 +34,11 @@ from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, + PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) +from .utils import object_to_path_node class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): @@ -1965,17 +1966,36 @@ class PathTraceView(ObjectView): return super().dispatch(request, *args, **kwargs) def get(self, request, pk): - obj = get_object_or_404(self.queryset, pk=pk) - path = obj.trace() - total_length = sum( - [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] - ) + related_paths = [] + + # If tracing a PathEndpoint, locate the CablePath (if one exists) by its origin + if isinstance(obj, PathEndpoint): + path = obj._path + # Otherwise, find all CablePaths which traverse the specified object + else: + related_paths = CablePath.objects.filter( + path__contains=[object_to_path_node(obj)] + ).prefetch_related('origin') + # Check for specification of a particular path (when tracing pass-through ports) + try: + path_id = int(request.GET.get('cablepath_id')) + except TypeError: + path_id = None + if path_id in list(related_paths.values_list('pk', flat=True)): + path = CablePath.objects.get(pk=path_id) + else: + path = related_paths.first() + + # total_length = sum( + # [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] + # ) return render(request, 'dcim/cable_trace.html', { 'obj': obj, - 'trace': path, - 'total_length': total_length, + 'path': path, + 'related_paths': related_paths, + # 'total_length': total_length, }) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 2f54f94ee..4b3f7f2d4 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -7,52 +7,70 @@ {% block content %}
    -
    -

    Near End

    -
    -
    - {% if total_length %}
    Total length: {{ total_length|floatformat:"-2" }} Meters
    {% endif %} -
    -
    -

    Far End

    -
    -
    - {% for near_end, cable, far_end in trace %} -
    -
    -

    {{ forloop.counter }}

    +
    + +
    +
    +

    Near End

    +
    +
    + {% if total_length %}
    Total length: {{ total_length|floatformat:"-2" }} Meters
    {% endif %} +
    +
    +

    Far End

    +
    -
    - {% include 'dcim/inc/cable_trace_end.html' with end=near_end %} -
    -
    - {% if cable %} -

    - - {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} - -

    -

    {{ cable.get_status_display }}

    -

    {{ cable.get_type_display|default:"" }}

    - {% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %} - {% if cable.color %} -   - {% endif %} - {% else %} -

    No Cable

    - {% endif %} -
    -
    - {% if far_end %} - {% include 'dcim/inc/cable_trace_end.html' with end=far_end %} - {% endif %} + {% for near_end, cable, far_end in path.origin.trace %} +
    +
    +

    {{ forloop.counter }}

    +
    +
    + {% include 'dcim/inc/cable_trace_end.html' with end=near_end %} +
    +
    + {% if cable %} +

    + + {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} + +

    +

    {{ cable.get_status_display }}

    +

    {{ cable.get_type_display|default:"" }}

    + {% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %} + {% if cable.color %} +   + {% endif %} + {% else %} +

    No Cable

    + {% endif %} +
    +
    + {% if far_end %} + {% include 'dcim/inc/cable_trace_end.html' with end=far_end %} + {% endif %} +
    +
    +
    + {% endfor %} +
    +
    +

    Trace completed!

    +
    +
    -
    - {% endfor %} -
    -
    -

    Trace completed!

    +
    + +

    Related Paths

    + +
    {% endblock %} From 56ee425227f384cef315c0e689b4fec52644cb92 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 09:41:45 -0400 Subject: [PATCH 102/291] Introduce PathContains lookup to allow filtering against objects in path directly --- netbox/dcim/fields.py | 5 ++++- netbox/dcim/lookups.py | 10 ++++++++++ netbox/dcim/signals.py | 9 ++++----- netbox/dcim/tests/test_cablepaths.py | 4 ++-- netbox/dcim/utils.py | 4 ---- netbox/dcim/views.py | 5 +---- 6 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 netbox/dcim/lookups.py diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 23b9b7fd5..21af2ed14 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,11 +1,11 @@ from django.contrib.postgres.fields import ArrayField -from django.contrib.postgres.validators import ArrayMaxLengthValidator from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from netaddr import AddrFormatError, EUI, mac_unix_expanded from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN +from .lookups import PathContains class ASNField(models.BigIntegerField): @@ -61,3 +61,6 @@ class PathField(ArrayField): def __init__(self, **kwargs): kwargs['base_field'] = models.CharField(max_length=40) super().__init__(**kwargs) + + +PathField.register_lookup(PathContains) diff --git a/netbox/dcim/lookups.py b/netbox/dcim/lookups.py new file mode 100644 index 000000000..03acc478a --- /dev/null +++ b/netbox/dcim/lookups.py @@ -0,0 +1,10 @@ +from django.contrib.postgres.fields.array import ArrayContains + +from dcim.utils import object_to_path_node + + +class PathContains(ArrayContains): + + def get_prep_lookup(self): + self.rhs = [object_to_path_node(self.rhs)] + return super().get_prep_lookup() diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 6079417c1..ee006c9d7 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -7,7 +7,7 @@ from django.dispatch import receiver from .choices import CableStatusChoices from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis -from .utils import object_to_path_node, trace_path +from .utils import trace_path def create_cablepath(node): @@ -24,8 +24,7 @@ def rebuild_paths(obj): """ Rebuild all CablePaths which traverse the specified node """ - node = object_to_path_node(obj) - cable_paths = CablePath.objects.filter(path__contains=[node]) + cable_paths = CablePath.objects.filter(path__contains=obj) with transaction.atomic(): for cp in cable_paths: @@ -86,7 +85,7 @@ def update_connected_endpoints(instance, created, **kwargs): # may change in the future.) However, we do need to capture status changes and update # any CablePaths accordingly. if instance.status != CableStatusChoices.STATUS_CONNECTED: - CablePath.objects.filter(path__contains=[object_to_path_node(instance)]).update(is_active=False) + CablePath.objects.filter(path__contains=instance).update(is_active=False) else: rebuild_paths(instance) @@ -109,7 +108,7 @@ def nullify_connected_endpoints(instance, **kwargs): instance.termination_b.save() # Delete and retrace any dependent cable paths - for cablepath in CablePath.objects.filter(path__contains=[object_to_path_node(instance)]): + for cablepath in CablePath.objects.filter(path__contains=instance): path, destination, is_active = trace_path(cablepath.origin) if path: CablePath.objects.filter(pk=cablepath.pk).update( diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 65de412cf..cfe63929d 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -4,7 +4,7 @@ from django.test import TestCase from circuits.models import * from dcim.choices import CableStatusChoices from dcim.models import * -from dcim.utils import objects_to_path +from dcim.utils import object_to_path_node class CablePathTestCase(TestCase): @@ -146,7 +146,7 @@ class CablePathTestCase(TestCase): kwargs['destination_type__isnull'] = True kwargs['destination_id__isnull'] = True if path is not None: - kwargs['path'] = objects_to_path(*path) + kwargs['path'] = [object_to_path_node(obj) for obj in path] if is_active is not None: kwargs['is_active'] = is_active if msg is None: diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 186ea72e5..d36cb1ad3 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -8,10 +8,6 @@ def object_to_path_node(obj): return f'{obj._meta.model_name}:{obj.pk}' -def objects_to_path(*obj_list): - return [object_to_path_node(obj) for obj in obj_list] - - def path_node_to_object(repr): model_name, object_id = repr.split(':') model_class = ContentType.objects.get(model=model_name).model_class() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3608b3792..63711a863 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -38,7 +38,6 @@ from .models import ( PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) -from .utils import object_to_path_node class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): @@ -1974,9 +1973,7 @@ class PathTraceView(ObjectView): path = obj._path # Otherwise, find all CablePaths which traverse the specified object else: - related_paths = CablePath.objects.filter( - path__contains=[object_to_path_node(obj)] - ).prefetch_related('origin') + related_paths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin') # Check for specification of a particular path (when tracing pass-through ports) try: path_id = int(request.GET.get('cablepath_id')) From ffdf5514aeba55ecc084d1b638128dd399a0a6cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 10:37:59 -0400 Subject: [PATCH 103/291] Tweak component templates --- netbox/templates/dcim/inc/endpoint_connection.html | 2 +- netbox/templates/dcim/inc/frontport.html | 2 +- netbox/templates/dcim/inc/rearport.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index 1c25a0e28..3169d2ffc 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,4 +1,4 @@ -{% if path %} +{% if path.destination_id %} {% with endpoint=path.destination %} {{ endpoint.parent }} {{ endpoint }} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index f267479f3..d362b6003 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -24,7 +24,7 @@ {# Description #} {{ frontport.description|placeholder }} - {# Cable/connection #} + {# Cable #} {% if frontport.cable %} {{ frontport.cable }} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index c1e5482d0..ce6edc883 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -23,7 +23,7 @@ {# Description #} {{ rearport.description|placeholder }} - {# Cable/connection #} + {# Cable #} {% if rearport.cable %} {{ rearport.cable }} From 6275c8c67d0e4e80f407cfee5d200a6f925d6c8f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 10:41:52 -0400 Subject: [PATCH 104/291] Prefetch path & destination for API views --- netbox/circuits/api/views.py | 2 +- netbox/dcim/api/views.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 1c8ad69e4..7b147412e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -59,7 +59,7 @@ class CircuitViewSet(CustomFieldModelViewSet): class CircuitTerminationViewSet(ModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', 'cable' + 'circuit', 'site', '_path__destination', 'cable' ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 6ed50324e..a804ad0b6 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -470,31 +470,31 @@ class DeviceViewSet(CustomFieldModelViewSet): # class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path', 'cable', 'tags') + queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsolePortFilterSet class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsoleServerPort.objects.prefetch_related('device', '_path', 'cable', 'tags') + queryset = ConsoleServerPort.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filters.ConsoleServerPortFilterSet class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path', 'cable', 'tags') + queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerPortFilterSet class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path', 'cable', 'tags') + queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filters.PowerOutletFilterSet class InterfaceViewSet(PathEndpointMixin, ModelViewSet): - queryset = Interface.objects.prefetch_related('device', '_path', 'cable', 'ip_addresses', 'tags') + queryset = Interface.objects.prefetch_related('device', '_path__destination', 'cable', 'ip_addresses', 'tags') serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilterSet @@ -597,7 +597,7 @@ class PowerPanelViewSet(ModelViewSet): # class PowerFeedViewSet(CustomFieldModelViewSet): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags') + queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', '_path__destination', 'cable', 'tags') serializer_class = serializers.PowerFeedSerializer filterset_class = filters.PowerFeedFilterSet From d59f0891e4d6c0b7ebca57b958d93d63329b367f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 12:10:12 -0400 Subject: [PATCH 105/291] Cache peer termination on CableTerminations --- .../migrations/0022_cache_cable_peer.py | 49 ++++++ .../dcim/migrations/0121_cache_cable_peer.py | 141 ++++++++++++++++++ netbox/dcim/models/device_components.py | 25 +++- netbox/dcim/signals.py | 4 + netbox/dcim/tests/test_models.py | 6 +- 5 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 netbox/circuits/migrations/0022_cache_cable_peer.py create mode 100644 netbox/dcim/migrations/0121_cache_cable_peer.py diff --git a/netbox/circuits/migrations/0022_cache_cable_peer.py b/netbox/circuits/migrations/0022_cache_cable_peer.py new file mode 100644 index 000000000..9a470a3c2 --- /dev/null +++ b/netbox/circuits/migrations/0022_cache_cable_peer.py @@ -0,0 +1,49 @@ +import sys + +from django.db import migrations, models +import django.db.models.deletion + + +def cache_cable_peers(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + Cable = apps.get_model('dcim', 'Cable') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + if 'test' not in sys.argv: + print(f"\n Updating circuit termination cable peers...", flush=True) + ct = ContentType.objects.get_for_model(CircuitTermination) + for cable in Cable.objects.filter(termination_a_type=ct): + CircuitTermination.objects.filter(pk=cable.termination_a_id).update( + _cable_peer_type_id=cable.termination_b_type_id, + _cable_peer_id=cable.termination_b_id + ) + for cable in Cable.objects.filter(termination_b_type=ct): + CircuitTermination.objects.filter(pk=cable.termination_b_id).update( + _cable_peer_type_id=cable.termination_a_type_id, + _cable_peer_id=cable.termination_a_id + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('circuits', '0021_cablepath'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='circuittermination', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=cache_cable_peers, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0121_cache_cable_peer.py b/netbox/dcim/migrations/0121_cache_cable_peer.py new file mode 100644 index 000000000..aeb89c5d3 --- /dev/null +++ b/netbox/dcim/migrations/0121_cache_cable_peer.py @@ -0,0 +1,141 @@ +import sys + +from django.db import migrations, models +import django.db.models.deletion + + +def cache_cable_peers(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + Cable = apps.get_model('dcim', 'Cable') + ConsolePort = apps.get_model('dcim', 'ConsolePort') + ConsoleServerPort = apps.get_model('dcim', 'ConsoleServerPort') + PowerPort = apps.get_model('dcim', 'PowerPort') + PowerOutlet = apps.get_model('dcim', 'PowerOutlet') + Interface = apps.get_model('dcim', 'Interface') + FrontPort = apps.get_model('dcim', 'FrontPort') + RearPort = apps.get_model('dcim', 'RearPort') + PowerFeed = apps.get_model('dcim', 'PowerFeed') + + models = ( + ConsolePort, + ConsoleServerPort, + PowerPort, + PowerOutlet, + Interface, + FrontPort, + RearPort, + PowerFeed + ) + + if 'test' not in sys.argv: + print("\n", end="") + + for model in models: + if 'test' not in sys.argv: + print(f" Updating {model._meta.verbose_name} cable peers...", flush=True) + ct = ContentType.objects.get_for_model(model) + for cable in Cable.objects.filter(termination_a_type=ct): + model.objects.filter(pk=cable.termination_a_id).update( + _cable_peer_type_id=cable.termination_b_type_id, + _cable_peer_id=cable.termination_b_id + ) + for cable in Cable.objects.filter(termination_b_type=ct): + model.objects.filter(pk=cable.termination_b_id).update( + _cable_peer_type_id=cable.termination_a_type_id, + _cable_peer_id=cable.termination_a_id + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0120_cablepath'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='consoleport', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='consoleserverport', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='frontport', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='frontport', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='interface', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='powerfeed', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='powerfeed', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='poweroutlet', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='poweroutlet', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='powerport', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='rearport', + name='_cable_peer_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rearport', + name='_cable_peer_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'), + ), + migrations.RunPython( + code=cache_cable_peers, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 2ab3ce7c8..1bd577cdf 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,4 +1,5 @@ -from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -99,6 +100,21 @@ class CableTermination(models.Model): blank=True, null=True ) + _cable_peer_type = models.ForeignKey( + to=ContentType, + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + _cable_peer_id = models.PositiveIntegerField( + blank=True, + null=True + ) + _cable_peer = GenericForeignKey( + ct_field='_cable_peer_type', + fk_field='_cable_peer_id' + ) # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted. _cabled_as_a = GenericRelation( @@ -116,12 +132,7 @@ class CableTermination(models.Model): abstract = True def get_cable_peer(self): - if self.cable is None: - return None - if self._cabled_as_a.exists(): - return self.cable.termination_b - if self._cabled_as_b.exists(): - return self.cable.termination_a + return self._cable_peer class PathEndpoint(models.Model): diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index ee006c9d7..5e5915313 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -67,10 +67,12 @@ def update_connected_endpoints(instance, created, **kwargs): if instance.termination_a.cable != instance: logger.debug(f"Updating termination A for cable {instance}") instance.termination_a.cable = instance + instance.termination_a._cable_peer = instance.termination_b instance.termination_a.save() if instance.termination_b.cable != instance: logger.debug(f"Updating termination B for cable {instance}") instance.termination_b.cable = instance + instance.termination_b._cable_peer = instance.termination_a instance.termination_b.save() # Create/update cable paths @@ -101,10 +103,12 @@ def nullify_connected_endpoints(instance, **kwargs): if instance.termination_a is not None: logger.debug(f"Nullifying termination A for cable {instance}") instance.termination_a.cable = None + instance.termination_a._cable_peer = None instance.termination_a.save() if instance.termination_b is not None: logger.debug(f"Nullifying termination B for cable {instance}") instance.termination_b.cable = None + instance.termination_b._cable_peer = None instance.termination_b.save() # Delete and retrace any dependent cable paths diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 83438a609..01829d7bc 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -398,9 +398,11 @@ class CableTestCase(TestCase): When a new Cable is created, it must be cached on either termination point. """ interface1 = Interface.objects.get(pk=self.interface1.pk) - self.assertEqual(self.cable.termination_a, interface1) interface2 = Interface.objects.get(pk=self.interface2.pk) + self.assertEqual(self.cable.termination_a, interface1) + self.assertEqual(interface1._cable_peer, interface2) self.assertEqual(self.cable.termination_b, interface2) + self.assertEqual(interface2._cable_peer, interface1) def test_cable_deletion(self): """ @@ -412,8 +414,10 @@ class CableTestCase(TestCase): self.assertNotEqual(str(self.cable), '#None') interface1 = Interface.objects.get(pk=self.interface1.pk) self.assertIsNone(interface1.cable) + self.assertIsNone(interface1._cable_peer) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertIsNone(interface2.cable) + self.assertIsNone(interface2._cable_peer) def test_cabletermination_deletion(self): """ From d984dbd83b50fd776a3dbc7e56b0d80689cda534 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 13:08:41 -0400 Subject: [PATCH 106/291] Extend device view to show local cable termination for all components --- netbox/templates/dcim/device.html | 9 +++++++-- .../templates/dcim/inc/cabletermination.html | 14 ++++++++++++++ netbox/templates/dcim/inc/consoleport.html | 15 +++++++++------ .../templates/dcim/inc/consoleserverport.html | 15 +++++++++------ netbox/templates/dcim/inc/frontport.html | 17 +---------------- netbox/templates/dcim/inc/interface.html | 18 +++++++++--------- netbox/templates/dcim/inc/poweroutlet.html | 15 +++++++++------ netbox/templates/dcim/inc/powerport.html | 15 +++++++++------ netbox/templates/dcim/inc/rearport.html | 17 +---------------- 9 files changed, 68 insertions(+), 67 deletions(-) create mode 100644 netbox/templates/dcim/inc/cabletermination.html diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 6c7b1c971..d66213a78 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -506,6 +506,7 @@ MTU Mode Cable + Cable Termination Connection @@ -566,7 +567,7 @@ Position Description Cable - Connection + Cable Termination @@ -623,7 +624,7 @@ Positions Description Cable - Connection + Cable Termination @@ -679,6 +680,7 @@ Type Description Cable + Cable Termination Connection @@ -732,6 +734,7 @@ Type Description Cable + Cable Termination Connection @@ -789,6 +792,7 @@ Draw Description Cable + Cable Termination Connection @@ -843,6 +847,7 @@ Input/Leg Description Cable + Cable Termination Connection diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html new file mode 100644 index 000000000..e4b28fbcf --- /dev/null +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -0,0 +1,14 @@ + + {% if termination.parent.provider %} + + + {{ termination.parent.provider }} + {{ termination.parent }} + + {% else %} + {{ termination.parent }} + {% endif %} + + + {{ termination }} + diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 912404be3..ace09cfe2 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -24,16 +24,19 @@ {# Cable #} - - {% if cp.cable %} + {% if cp.cable %} + {{ cp.cable }} - {% else %} - — - {% endif %} - + + {% include 'dcim/inc/cabletermination.html' with termination=cp.get_cable_peer %} + {% else %} + + Not connected + + {% endif %} {# Connection #} {% include 'dcim/inc/endpoint_connection.html' with path=cp.path %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index b7a5c6b56..025b0bf02 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -26,16 +26,19 @@ {# Cable #} - - {% if csp.cable %} + {% if csp.cable %} + {{ csp.cable }} - {% else %} - - {% endif %} - + + {% include 'dcim/inc/cabletermination.html' with termination=csp.get_cable_peer %} + {% else %} + + Not connected + + {% endif %} {# Connection #} {% include 'dcim/inc/endpoint_connection.html' with path=csp.path %} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index d362b6003..91374cb1e 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -32,22 +32,7 @@ - {% with far_end=frontport.get_cable_peer %} - - {% if far_end.parent.provider %} - - - {{ far_end.parent.provider }} - {{ far_end.parent }} - - {% else %} - - {{ far_end.parent }} - - {% endif %} - - {{ far_end }} - {% endwith %} + {% include 'dcim/inc/cabletermination.html' with termination=frontport.get_cable_peer %} {% else %} Not connected diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 159551192..efaed7ecf 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -45,19 +45,19 @@ {{ iface.get_mode_display|default:"—" }} {# Cable #} - - {% if iface.cable %} + {% if iface.cable %} + {{ iface.cable }} - {% if iface.cable.color %} -   - {% endif %} - {% else %} - - {% endif %} - + + {% include 'dcim/inc/cabletermination.html' with termination=iface.get_cable_peer %} + {% else %} + + Not connected + + {% endif %} {# Connection or type #} {% if iface.is_lag %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index b3e003e99..38eb7b8a6 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -37,16 +37,19 @@ {# Cable #} - - {% if po.cable %} + {% if po.cable %} + {{ po.cable }} - {% else %} - - {% endif %} - + + {% include 'dcim/inc/cabletermination.html' with termination=po.get_cable_peer %} + {% else %} + + Not connected + + {% endif %} {# Connection #} {% with path=po.path %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index c65b685d7..c5c18c093 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -33,16 +33,19 @@ {# Cable #} - - {% if pp.cable %} + {% if pp.cable %} + {{ pp.cable }} - {% else %} - — - {% endif %} - + + {% include 'dcim/inc/cabletermination.html' with termination=pp.get_cable_peer %} + {% else %} + + Not connected + + {% endif %} {# Connection #} {% include 'dcim/inc/endpoint_connection.html' with path=pp.path %} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index ce6edc883..fd5ee620c 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -31,22 +31,7 @@ - {% with far_end=rearport.get_cable_peer %} - - {% if far_end.parent.provider %} - - - {{ far_end.parent.provider }} - {{ far_end.parent }} - - {% else %} - - {{ far_end.parent }} - - {% endif %} - - {{ far_end }} - {% endwith %} + {% include 'dcim/inc/cabletermination.html' with termination=rearport.get_cable_peer %} {% else %} Not connected From c813ae4f0494a72571e8fbf5bd5f33c0db6573d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 13:30:28 -0400 Subject: [PATCH 107/291] Clean up power connection tables --- netbox/dcim/tables.py | 27 ++++++++++++++++++++-- netbox/templates/dcim/device.html | 2 -- netbox/templates/dcim/inc/poweroutlet.html | 5 +--- netbox/templates/dcim/inc/powerport.html | 5 +--- netbox/templates/dcim/powerpanel.html | 10 ++++---- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index b1aa6e57d..437daaf29 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -67,6 +67,19 @@ INTERFACE_TAGGED_VLANS = """ {% endfor %} """ +POWERFEED_CABLE = """ +{{ value }} + + + +""" + +POWERFEED_CABLETERMINATION = """ +{{ value.parent }} + +{{ value }} +""" + # # Regions @@ -977,6 +990,15 @@ class PowerFeedTable(BaseTable): max_utilization = tables.TemplateColumn( template_code="{{ value }}%" ) + cable = tables.TemplateColumn( + template_code=POWERFEED_CABLE, + orderable=False + ) + connection = tables.TemplateColumn( + accessor='get_cable_peer', + template_code=POWERFEED_CABLETERMINATION, + orderable=False + ) available_power = tables.Column( verbose_name='Available power (VA)' ) @@ -988,8 +1010,9 @@ class PowerFeedTable(BaseTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'available_power', 'tags', + 'max_utilization', 'cable', 'connection', 'available_power', 'tags', ) default_columns = ( - 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', + 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', + 'connection', ) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d66213a78..c06f86bf0 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -792,7 +792,6 @@ Draw Description Cable - Cable Termination Connection @@ -847,7 +846,6 @@ Input/Leg Description Cable - Cable Termination Connection diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 38eb7b8a6..a6a0dd03e 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -44,11 +44,8 @@ - {% include 'dcim/inc/cabletermination.html' with termination=po.get_cable_peer %} {% else %} - - Not connected - + Not connected {% endif %} {# Connection #} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index c5c18c093..125bc5445 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -40,11 +40,8 @@ - {% include 'dcim/inc/cabletermination.html' with termination=pp.get_cable_peer %} {% else %} - - Not connected - + Not connected {% endif %} {# Connection #} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 3cad8b5b3..46b386cf3 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -58,7 +58,7 @@ {% block content %}
    -
    +
    Power Panel @@ -82,17 +82,17 @@
    - {% include 'inc/custom_fields_panel.html' with obj=powerpanel %} - {% include 'extras/inc/tags_panel.html' with tags=powerpanel.tags.all url='dcim:powerpanel_list' %} {% plugin_left_page powerpanel %}
    -
    - {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} +
    + {% include 'inc/custom_fields_panel.html' with obj=powerpanel %} + {% include 'extras/inc/tags_panel.html' with tags=powerpanel.tags.all url='dcim:powerpanel_list' %} {% plugin_right_page powerpanel %}
    + {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} {% plugin_full_width_page powerpanel %}
    From 23cde6d1b8a48cb55a3b76d99ccbadf11e782be9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 14:30:46 -0400 Subject: [PATCH 108/291] Include cable_peer on CableTermination serializers --- docs/release-notes/version-2.10.md | 35 ++++++++++++----- netbox/circuits/api/serializers.py | 4 +- netbox/dcim/api/serializers.py | 60 ++++++++++++++++++++++-------- netbox/dcim/api/views.py | 18 ++++++--- 4 files changed, 84 insertions(+), 33 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index ee49f223f..124bbd645 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -60,20 +60,35 @@ All end-to-end cable paths are now cached using the new CablePath model. This al ### REST API Changes -* Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints -* circuits.CircuitTermination: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) +* Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete) +* circuits.CircuitTermination: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` * dcim.Cable: Added `custom_fields` -* dcim.ConsolePort: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) -* dcim.ConsoleServerPort: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) -* dcim.FrontPort: Removed the `trace` endpoint -* dcim.Interface: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) +* dcim.ConsolePort: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` +* dcim.ConsoleServerPort: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` +* dcim.FrontPort: + * Removed the `trace` endpoint + * Added `cable_peer` +* dcim.Interface: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning -* dcim.PowerFeed: Add fields `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` -* dcim.PowerOutlet: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) +* dcim.PowerFeed: Add fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, and `cable_peer` +* dcim.PowerOutlet: + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` * dcim.PowerPanel: Added `custom_fields` -* dcim.PowerPort: Replaced `connection_status` with `connected_endpoint_reachable` (boolean) +* dcim.PowerPort + * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) + * Added `cable_peer` * dcim.RackReservation: Added `custom_fields` -* dcim.RearPort: Removed the `trace` endpoint +* dcim.RearPort: + * Removed the `trace` endpoint * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 03c9012af..6bbb0cef5 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer -from dcim.api.serializers import ConnectedEndpointSerializer +from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from tenancy.api.nested_serializers import NestedTenantSerializer @@ -67,7 +67,7 @@ class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): ] -class CircuitTerminationSerializer(ConnectedEndpointSerializer): +class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer() diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 031ce575b..803b64fbb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -27,6 +27,27 @@ from virtualization.api.nested_serializers import NestedClusterSerializer from .nested_serializers import * +class CableTerminationSerializer(serializers.ModelSerializer): + cable_peer_type = serializers.SerializerMethodField(read_only=True) + cable_peer = serializers.SerializerMethodField(read_only=True) + + def get_cable_peer_type(self, obj): + if obj._cable_peer is not None: + return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}' + return None + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_cable_peer(self, obj): + """ + Return the appropriate serializer for the cable termination model. + """ + if obj._cable_peer is not None: + serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj._cable_peer, context=context).data + return None + + class ConnectedEndpointSerializer(ValidatedModelSerializer): connected_endpoint_type = serializers.SerializerMethodField(read_only=True) connected_endpoint = serializers.SerializerMethodField(read_only=True) @@ -452,7 +473,7 @@ class DeviceNAPALMSerializer(serializers.Serializer): method = serializers.DictField() -class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -466,11 +487,11 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSeria model = ConsoleServerPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', + 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'tags', ] -class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -484,11 +505,11 @@ class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer) model = ConsolePort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', + 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'tags', ] -class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -512,11 +533,12 @@ class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer) model = PowerOutlet fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', + 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', + 'tags', ] -class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -530,11 +552,12 @@ class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): model = PowerPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'tags', + 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', + 'tags', ] -class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) @@ -555,7 +578,7 @@ class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', + 'cable_peer', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', ] # TODO: This validation should be handled by Interface.clean() @@ -579,7 +602,7 @@ class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): return super().validate(data) -class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -587,7 +610,9 @@ class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class Meta: model = RearPort - fields = ['id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags'] + fields = [ + 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', + ] class FrontPortRearPortSerializer(WritableNestedSerializer): @@ -601,7 +626,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'label'] -class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -612,7 +637,7 @@ class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): model = FrontPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'tags', + 'cable_peer', 'tags', ] @@ -760,7 +785,12 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer, CustomFieldModelSerializer): +class PowerFeedSerializer( + TaggedObjectSerializer, + CableTerminationSerializer, + ConnectedEndpointSerializer, + CustomFieldModelSerializer +): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a804ad0b6..14d6177bb 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -470,31 +470,35 @@ class DeviceViewSet(CustomFieldModelViewSet): # class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') + queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsolePortFilterSet class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsoleServerPort.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') + queryset = ConsoleServerPort.objects.prefetch_related( + 'device', '_path__destination', 'cable', '_cable_peer', 'tags' + ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filters.ConsoleServerPortFilterSet class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') + queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerPortFilterSet class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', 'tags') + queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filters.PowerOutletFilterSet class InterfaceViewSet(PathEndpointMixin, ModelViewSet): - queryset = Interface.objects.prefetch_related('device', '_path__destination', 'cable', 'ip_addresses', 'tags') + queryset = Interface.objects.prefetch_related( + 'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' + ) serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilterSet @@ -597,7 +601,9 @@ class PowerPanelViewSet(ModelViewSet): # class PowerFeedViewSet(CustomFieldModelViewSet): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', '_path__destination', 'cable', 'tags') + queryset = PowerFeed.objects.prefetch_related( + 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags' + ) serializer_class = serializers.PowerFeedSerializer filterset_class = filters.PowerFeedFilterSet From 3870f5d2466a69481830025bf8df16c040f2c2f0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 15:26:59 -0400 Subject: [PATCH 109/291] Remove unused CablePathManager --- netbox/dcim/managers.py | 8 -------- netbox/dcim/models/devices.py | 3 --- 2 files changed, 11 deletions(-) delete mode 100644 netbox/dcim/managers.py diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py deleted file mode 100644 index 903a6feac..000000000 --- a/netbox/dcim/managers.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.db.models import Manager - - -class CablePathManager(Manager): - - def create_for_endpoint(self, endpoint): - ct = ContentType.objects.get_for_model(endpoint) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 1f9db0986..e6c6134c6 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -15,7 +15,6 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.fields import PathField -from dcim.managers import CablePathManager from dcim.utils import path_node_to_object from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem from extras.utils import extras_features @@ -1195,8 +1194,6 @@ class CablePath(models.Model): default=False ) - objects = CablePathManager() - class Meta: unique_together = ('origin_type', 'origin_id') From 52ec35b94f25a2a9764f10b3d0868a1023c34411 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 15:27:40 -0400 Subject: [PATCH 110/291] Correct serializer field lists --- netbox/circuits/api/serializers.py | 1 + netbox/dcim/api/serializers.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 6bbb0cef5..9bc95f065 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -78,4 +78,5 @@ class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpoint fields = [ 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', + 'cable_peer', 'cable_peer_type', ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 803b64fbb..11e286132 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -487,7 +487,7 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial model = ConsoleServerPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'tags', + 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'cable_peer_type', 'tags', ] @@ -505,7 +505,7 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, model = ConsolePort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'tags', + 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'cable_peer_type', 'tags', ] @@ -534,7 +534,7 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', - 'tags', + 'cable_peer_type', 'tags', ] @@ -553,7 +553,7 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', - 'tags', + 'cable_peer_type', 'tags', ] @@ -578,7 +578,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', - 'cable_peer', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', + 'cable_peer', 'cable_peer_type', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', ] # TODO: This validation should be handled by Interface.clean() @@ -611,7 +611,8 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Val class Meta: model = RearPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', + 'cable_peer_type', 'tags', ] @@ -637,7 +638,7 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Va model = FrontPort fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'cable_peer', 'tags', + 'cable_peer', 'cable_peer_type', 'tags', ] @@ -821,5 +822,6 @@ class PowerFeedSerializer( fields = [ 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', + 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', + 'cable_peer_type', ] From 534364a30fc00e6bb927c15113ed4495ca86e70f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 15:48:52 -0400 Subject: [PATCH 111/291] Improve model docstrings --- netbox/dcim/models/device_components.py | 20 +++++++++++++++++++- netbox/dcim/models/devices.py | 22 +++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 1bd577cdf..6e93dd768 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -39,6 +39,9 @@ __all__ = ( class ComponentModel(models.Model): + """ + An abstract model inherited by any model which has a parent Device. + """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, @@ -93,6 +96,14 @@ class ComponentModel(models.Model): class CableTermination(models.Model): + """ + An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and + CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance. + + `_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a + shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in + dcim.signals when a Cable instance is created or deleted, respectively. + """ cable = models.ForeignKey( to='dcim.Cable', on_delete=models.SET_NULL, @@ -137,7 +148,14 @@ class CableTermination(models.Model): class PathEndpoint(models.Model): """ - Any object which may serve as the originating endpoint of a CablePath. + An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically, + these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination. + + `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in + dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the + CablePath model. `_path` should not be accessed directly; rather, use the `path` property. + + `connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any. """ _path = models.ForeignKey( to='dcim.CablePath', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e6c6134c6..e4ea551b9 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1162,7 +1162,27 @@ class Cable(ChangeLoggedModel, CustomFieldModel): class CablePath(models.Model): """ - An array of objects conveying the end-to-end path of one or more Cables. + A CablePath instance represents the physical path from an origin to a destination, including all intermediate + elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do + not terminate on a PathEndpoint). + + `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the + path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following + topology: + + 1 2 3 + Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B + + This path would be expressed as: + + CablePath( + origin = Interface A + destination = Interface B + path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3] + ) + + `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of + "connected". """ origin_type = models.ForeignKey( to=ContentType, From 6b3a1998c83f50bbda7379da685ccf6a85cdddf9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 15:59:21 -0400 Subject: [PATCH 112/291] Add test_is_connected to CircuitTerminationTestCase --- netbox/circuits/tests/test_filters.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 9756c320b..b0861a7c0 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -3,7 +3,7 @@ from django.test import TestCase from circuits.choices import * from circuits.filters import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from dcim.models import Region, Site +from dcim.models import Cable, Region, Site from tenancy.models import Tenant, TenantGroup @@ -286,6 +286,8 @@ class CircuitTerminationTestCase(TestCase): )) CircuitTermination.objects.bulk_create(circuit_terminations) + Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save() + def test_term_side(self): params = {'term_side': 'A'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) @@ -313,3 +315,7 @@ class CircuitTerminationTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_is_connected(self): + params = {'is_connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From a6e0ef8cd84e0ec08b60425b8178b065146cdf52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 16:15:18 -0400 Subject: [PATCH 113/291] Clean up console/power/interface connections views --- netbox/dcim/views.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 63711a863..696b7f356 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1117,7 +1117,7 @@ class DeviceLLDPNeighborsView(ObjectView): def get(self, request, pk): device = get_object_or_404(self.queryset, pk=pk) - interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related('_path').exclude( + interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related('_path__destination').exclude( type__in=NONCONNECTABLE_IFACE_TYPES ) @@ -2087,7 +2087,7 @@ class CableBulkDeleteView(BulkDeleteView): class ConsoleConnectionsListView(ObjectListView): queryset = ConsolePort.objects.prefetch_related( - 'device', '_path__destination__device' + 'device', '_path__destination' ).filter(_path__isnull=False).order_by('device') filterset = filters.ConsoleConnectionFilterSet filterset_form = forms.ConsoleConnectionFilterForm @@ -2097,7 +2097,7 @@ class ConsoleConnectionsListView(ObjectListView): def queryset_to_csv(self): csv_data = [ # Headers - ','.join(['console_server', 'port', 'device', 'console_port', 'connection_status']) + ','.join(['console_server', 'port', 'device', 'console_port', 'reachable']) ] for obj in self.queryset: csv = csv_format([ @@ -2105,7 +2105,7 @@ class ConsoleConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Reachable' if obj._path.is_active else 'Not Reachable', + obj._path.is_active ]) csv_data.append(csv) @@ -2114,7 +2114,7 @@ class ConsoleConnectionsListView(ObjectListView): class PowerConnectionsListView(ObjectListView): queryset = PowerPort.objects.prefetch_related( - 'device', '_path__destination__device' + 'device', '_path__destination' ).filter(_path__isnull=False).order_by('device') filterset = filters.PowerConnectionFilterSet filterset_form = forms.PowerConnectionFilterForm @@ -2124,7 +2124,7 @@ class PowerConnectionsListView(ObjectListView): def queryset_to_csv(self): csv_data = [ # Headers - ','.join(['pdu', 'outlet', 'device', 'power_port', 'connection_status']) + ','.join(['pdu', 'outlet', 'device', 'power_port', 'reachable']) ] for obj in self.queryset: csv = csv_format([ @@ -2132,7 +2132,7 @@ class PowerConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Reachable' if obj._path.is_active else 'Not Reachable', + obj._path.is_active ]) csv_data.append(csv) @@ -2141,7 +2141,7 @@ class PowerConnectionsListView(ObjectListView): class InterfaceConnectionsListView(ObjectListView): queryset = Interface.objects.prefetch_related( - 'device', '_path__destination__device' + 'device', '_path__destination' ).filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair _path__isnull=False, @@ -2156,7 +2156,7 @@ class InterfaceConnectionsListView(ObjectListView): csv_data = [ # Headers ','.join([ - 'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status' + 'device_a', 'interface_a', 'device_b', 'interface_b', 'reachable' ]) ] for obj in self.queryset: @@ -2165,7 +2165,7 @@ class InterfaceConnectionsListView(ObjectListView): obj._path.destination.name if obj._path.destination else None, obj.device.identifier, obj.name, - 'Reachable' if obj._path.is_active else 'Not Reachable', + obj._path.is_active ]) csv_data.append(csv) From a072d4059420a13b7dadd3184a3d22536ec3c8ad Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 16:16:08 -0400 Subject: [PATCH 114/291] Update v2.10 changelog --- docs/release-notes/version-2.10.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 124bbd645..253ed0612 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -63,32 +63,29 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete) * circuits.CircuitTermination: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` + * Added `cable_peer` and `cable_peer_type` * dcim.Cable: Added `custom_fields` * dcim.ConsolePort: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` + * Added `cable_peer` and `cable_peer_type` * dcim.ConsoleServerPort: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` -* dcim.FrontPort: - * Removed the `trace` endpoint - * Added `cable_peer` + * Added `cable_peer` and `cable_peer_type` +* dcim.FrontPort: Added `cable_peer` and `cable_peer_type` * dcim.Interface: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` + * Added `cable_peer` and `cable_peer_type` * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning -* dcim.PowerFeed: Add fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, and `cable_peer` +* dcim.PowerFeed: Add fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type` * dcim.PowerOutlet: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` + * Added `cable_peer` and `cable_peer_type` * dcim.PowerPanel: Added `custom_fields` * dcim.PowerPort * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) - * Added `cable_peer` + * Added `cable_peer` and `cable_peer_type` * dcim.RackReservation: Added `custom_fields` -* dcim.RearPort: - * Removed the `trace` endpoint +* dcim.RearPort: Added `cable_peer` and `cable_peer_type` * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) From 2c9ae60dec31732d5db77ad585eb662580c126b4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 16:34:03 -0400 Subject: [PATCH 115/291] Optimize path node representations --- netbox/dcim/utils.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index d36cb1ad3..ccc849aa5 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -5,12 +5,20 @@ from .exceptions import CableTraceSplit def object_to_path_node(obj): - return f'{obj._meta.model_name}:{obj.pk}' + """ + Return a representation of an object suitable for inclusion in a CablePath path. Node representation is in the + form :. + """ + ct = ContentType.objects.get_for_model(obj) + return f'{ct.pk}:{obj.pk}' def path_node_to_object(repr): - model_name, object_id = repr.split(':') - model_class = ContentType.objects.get(model=model_name).model_class() + """ + Given a path node representation, return the corresponding object. + """ + ct_id, object_id = repr.split(':') + model_class = ContentType.objects.get(pk=ct_id).model_class() return model_class.objects.get(pk=int(object_id)) From 44b842592ae803b4cd6d1bbda6d11170035f4f9a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 16:58:11 -0400 Subject: [PATCH 116/291] Restore total length count on trace view --- docs/release-notes/version-2.10.md | 8 ++++++-- netbox/dcim/models/devices.py | 14 ++++++++++++-- netbox/dcim/utils.py | 13 +++++++++++-- netbox/dcim/views.py | 6 +----- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 253ed0612..fa3f3aea8 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -71,7 +71,9 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * dcim.ConsoleServerPort: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` -* dcim.FrontPort: Added `cable_peer` and `cable_peer_type` +* dcim.FrontPort: + * Removed the `/trace/` endpoint + * Added `cable_peer` and `cable_peer_type` * dcim.Interface: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` @@ -85,7 +87,9 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` * dcim.RackReservation: Added `custom_fields` -* dcim.RearPort: Added `cable_peer` and `cable_peer_type` +* dcim.RearPort: + * Removed the `/trace/` endpoint + * Added `cable_peer` and `cable_peer_type` * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed * extras.Graph: This API endpoint has been removed (see #4349) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e4ea551b9..b44146b99 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -7,7 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, ProtectedError +from django.db.models import F, ProtectedError, Sum from django.urls import reverse from django.utils.safestring import mark_safe from taggit.managers import TaggableManager @@ -15,7 +15,7 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.fields import PathField -from dcim.utils import path_node_to_object +from dcim.utils import decompile_path_node, path_node_to_object from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.choices import ColorChoices @@ -1228,6 +1228,16 @@ class CablePath(models.Model): model = self.origin._meta.model model.objects.filter(pk=self.origin.pk).update(_path=self.pk) + def get_total_length(self): + """ + Return the sum of the length of each cable in the path. + """ + cable_ids = [ + # Starting from the first element, every third element in the path should be a Cable + decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) + ] + return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total'] + # # Virtual chassis diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index ccc849aa5..52b0a4232 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -4,20 +4,29 @@ from .choices import CableStatusChoices from .exceptions import CableTraceSplit +def compile_path_node(ct_id, object_id): + return f'{ct_id}:{object_id}' + + +def decompile_path_node(repr): + ct_id, object_id = repr.split(':') + return int(ct_id), int(object_id) + + def object_to_path_node(obj): """ Return a representation of an object suitable for inclusion in a CablePath path. Node representation is in the form :. """ ct = ContentType.objects.get_for_model(obj) - return f'{ct.pk}:{obj.pk}' + return compile_path_node(ct.pk, obj.pk) def path_node_to_object(repr): """ Given a path node representation, return the corresponding object. """ - ct_id, object_id = repr.split(':') + ct_id, object_id = decompile_path_node(repr) model_class = ContentType.objects.get(pk=ct_id).model_class() return model_class.objects.get(pk=int(object_id)) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 696b7f356..ff4ec1ef6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1984,15 +1984,11 @@ class PathTraceView(ObjectView): else: path = related_paths.first() - # total_length = sum( - # [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] - # ) - return render(request, 'dcim/cable_trace.html', { 'obj': obj, 'path': path, 'related_paths': related_paths, - # 'total_length': total_length, + 'total_length': path.get_total_length(), }) From c7c66626b6806c6da17f5bb7ad61d6743ca51f00 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Oct 2020 17:28:25 -0400 Subject: [PATCH 117/291] Standardize 'cabled' and 'connected' filters; complete tests --- netbox/circuits/filters.py | 4 +- netbox/circuits/tests/test_filters.py | 10 ++- netbox/dcim/filters.py | 92 ++++++++++++--------------- netbox/dcim/tests/test_filters.py | 54 +++++++++++++--- 4 files changed, 94 insertions(+), 66 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index ebc0d0ec1..c573603c6 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.filters import PathEndpointFilterSet +from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet from dcim.models import Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet @@ -145,7 +145,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr ).distinct() -class CircuitTerminationFilterSet(BaseFilterSet, PathEndpointFilterSet): +class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index b0861a7c0..73701be03 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -316,6 +316,12 @@ class CircuitTerminationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_is_connected(self): - params = {'is_connected': True} + def test_cabled(self): + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index aeccc341b..9690ee195 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -24,6 +24,7 @@ from .models import ( __all__ = ( 'CableFilterSet', + 'CableTerminationFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', 'ConsolePortTemplateFilterSet', @@ -41,6 +42,7 @@ __all__ = ( 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', 'ManufacturerFilterSet', + 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', 'PowerFeedFilterSet', @@ -753,81 +755,76 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) -class PathEndpointFilterSet(django_filters.FilterSet): - is_connected = django_filters.BooleanFilter( - method='filter_is_connected', - label='Search', - ) - - def filter_is_connected(self, queryset, name, value): - return queryset.filter(_path__is_active=True) - - -class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): - type = django_filters.MultipleChoiceFilter( - choices=ConsolePortTypeChoices, - null_value=None - ) +class CableTerminationFilterSet(django_filters.FilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', exclude=True ) + +class PathEndpointFilterSet(django_filters.FilterSet): + connected = django_filters.BooleanFilter( + method='filter_connected' + ) + + def filter_connected(self, queryset, name, value): + if value: + return queryset.filter(_path__is_active=True) + else: + return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) + + +class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=ConsolePortTypeChoices, + null_value=None + ) + class Meta: model = ConsolePort fields = ['id', 'name', 'description'] -class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): +class ConsoleServerPortFilterSet( + BaseFilterSet, + DeviceComponentFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) class Meta: model = ConsoleServerPort fields = ['id', 'name', 'description'] -class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): +class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) class Meta: model = PowerPort fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): +class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) class Meta: model = PowerOutlet fields = ['id', 'name', 'feed_leg', 'description'] -class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFilterSet): +class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -844,11 +841,6 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFi field_name='pk', label='Device (ID)', ) - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) kind = django_filters.CharFilter( method='filter_kind', label='Kind of interface', @@ -925,24 +917,14 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, PathEndpointFi }.get(value, queryset.none()) -class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) +class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): class Meta: model = FrontPort fields = ['id', 'name', 'type', 'description'] -class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): - cabled = django_filters.BooleanFilter( - field_name='cable', - lookup_expr='isnull', - exclude=True - ) +class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): class Meta: model = RearPort @@ -1266,7 +1248,13 @@ class PowerPanelFilterSet(BaseFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(BaseFilterSet, PathEndpointFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class PowerFeedFilterSet( + BaseFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet, + CustomFieldFilterSet, + CreatedUpdatedFilterSet +): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index c399e1a92..f209cd1f4 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1514,9 +1514,11 @@ class ConsolePortTestCase(TestCase): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_is_connected(self): - params = {'is_connected': True} + def test_connected(self): + params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_region(self): regions = Region.objects.all()[:2] @@ -1608,9 +1610,11 @@ class ConsoleServerPortTestCase(TestCase): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_is_connected(self): - params = {'is_connected': True} + def test_connected(self): + params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_region(self): regions = Region.objects.all()[:2] @@ -1710,9 +1714,11 @@ class PowerPortTestCase(TestCase): params = {'allocated_draw': [50, 100]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_is_connected(self): - params = {'is_connected': True} + def test_connected(self): + params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_region(self): regions = Region.objects.all()[:2] @@ -1809,9 +1815,11 @@ class PowerOutletTestCase(TestCase): params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_is_connected(self): - params = {'is_connected': True} + def test_connected(self): + params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_region(self): regions = Region.objects.all()[:2] @@ -1896,9 +1904,11 @@ class InterfaceTestCase(TestCase): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_is_connected(self): - params = {'is_connected': True} + def test_connected(self): + params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_enabled(self): params = {'enabled': 'true'} @@ -2657,6 +2667,18 @@ class PowerFeedTestCase(TestCase): ) PowerFeed.objects.bulk_create(power_feeds) + manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model', slug='model') + device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') + device = Device.objects.create(name='Device', device_type=device_type, device_role=device_role, site=sites[0]) + power_ports = [ + PowerPort(device=device, name='Power Port 1'), + PowerPort(device=device, name='Power Port 2'), + ] + PowerPort.objects.bulk_create(power_ports) + Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save() + Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save() + def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2718,5 +2740,17 @@ class PowerFeedTestCase(TestCase): params = {'rack_id': [racks[0].pk, racks[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cabled(self): + params = {'cabled': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'cabled': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + # TODO: Connection filters From 6db3c65bcc335640cc792632b043e993b685069e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Oct 2020 09:50:12 -0400 Subject: [PATCH 118/291] Swap order of cabling migrations --- .../{0022_cache_cable_peer.py => 0021_cache_cable_peer.py} | 2 +- .../migrations/{0021_cablepath.py => 0022_cablepath.py} | 6 ++---- .../{0121_cache_cable_peer.py => 0120_cache_cable_peer.py} | 2 +- .../migrations/{0120_cablepath.py => 0121_cablepath.py} | 4 +--- 4 files changed, 5 insertions(+), 9 deletions(-) rename netbox/circuits/migrations/{0022_cache_cable_peer.py => 0021_cache_cable_peer.py} (97%) rename netbox/circuits/migrations/{0021_cablepath.py => 0022_cablepath.py} (83%) rename netbox/dcim/migrations/{0121_cache_cable_peer.py => 0120_cache_cable_peer.py} (99%) rename netbox/dcim/migrations/{0120_cablepath.py => 0121_cablepath.py} (97%) diff --git a/netbox/circuits/migrations/0022_cache_cable_peer.py b/netbox/circuits/migrations/0021_cache_cable_peer.py similarity index 97% rename from netbox/circuits/migrations/0022_cache_cable_peer.py rename to netbox/circuits/migrations/0021_cache_cable_peer.py index 9a470a3c2..630c3b4ec 100644 --- a/netbox/circuits/migrations/0022_cache_cable_peer.py +++ b/netbox/circuits/migrations/0021_cache_cable_peer.py @@ -28,7 +28,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('circuits', '0021_cablepath'), + ('circuits', '0020_custom_field_data'), ] operations = [ diff --git a/netbox/circuits/migrations/0021_cablepath.py b/netbox/circuits/migrations/0022_cablepath.py similarity index 83% rename from netbox/circuits/migrations/0021_cablepath.py rename to netbox/circuits/migrations/0022_cablepath.py index b416d2e9f..4a5b26efa 100644 --- a/netbox/circuits/migrations/0021_cablepath.py +++ b/netbox/circuits/migrations/0022_cablepath.py @@ -1,5 +1,3 @@ -# Generated by Django 3.1 on 2020-10-02 19:43 - from django.db import migrations, models import django.db.models.deletion @@ -7,8 +5,8 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('dcim', '0120_cablepath'), - ('circuits', '0020_custom_field_data'), + ('dcim', '0121_cablepath'), + ('circuits', '0021_cache_cable_peer'), ] operations = [ diff --git a/netbox/dcim/migrations/0121_cache_cable_peer.py b/netbox/dcim/migrations/0120_cache_cable_peer.py similarity index 99% rename from netbox/dcim/migrations/0121_cache_cable_peer.py rename to netbox/dcim/migrations/0120_cache_cable_peer.py index aeb89c5d3..c45d03396 100644 --- a/netbox/dcim/migrations/0121_cache_cable_peer.py +++ b/netbox/dcim/migrations/0120_cache_cable_peer.py @@ -50,7 +50,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0120_cablepath'), + ('dcim', '0119_inventoryitem_mptt_rebuild'), ] operations = [ diff --git a/netbox/dcim/migrations/0120_cablepath.py b/netbox/dcim/migrations/0121_cablepath.py similarity index 97% rename from netbox/dcim/migrations/0120_cablepath.py rename to netbox/dcim/migrations/0121_cablepath.py index dd3c4ed19..737e59b32 100644 --- a/netbox/dcim/migrations/0120_cablepath.py +++ b/netbox/dcim/migrations/0121_cablepath.py @@ -1,5 +1,3 @@ -# Generated by Django 3.1 on 2020-10-02 19:43 - import dcim.fields from django.db import migrations, models import django.db.models.deletion @@ -9,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0119_inventoryitem_mptt_rebuild'), + ('dcim', '0120_cache_cable_peer'), ] operations = [ From f560693748d1f35e79ad9d006a8a9b75ef5ae37b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Oct 2020 10:23:15 -0400 Subject: [PATCH 119/291] Rewrite trace_paths management command and call in upgrade.sh --- .../dcim/management/commands/retrace_paths.py | 68 ---------------- .../dcim/management/commands/trace_paths.py | 81 +++++++++++++++++++ upgrade.sh | 5 ++ 3 files changed, 86 insertions(+), 68 deletions(-) delete mode 100644 netbox/dcim/management/commands/retrace_paths.py create mode 100644 netbox/dcim/management/commands/trace_paths.py diff --git a/netbox/dcim/management/commands/retrace_paths.py b/netbox/dcim/management/commands/retrace_paths.py deleted file mode 100644 index d11a85417..000000000 --- a/netbox/dcim/management/commands/retrace_paths.py +++ /dev/null @@ -1,68 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.core.management.base import BaseCommand -from django.core.management.color import no_style -from django.db import connection -from django.db.models import Q - -from dcim.models import CablePath -from dcim.signals import create_cablepath - -ENDPOINT_MODELS = ( - 'circuits.CircuitTermination', - 'dcim.ConsolePort', - 'dcim.ConsoleServerPort', - 'dcim.Interface', - 'dcim.PowerFeed', - 'dcim.PowerOutlet', - 'dcim.PowerPort', -) - - -class Command(BaseCommand): - help = "Recalculate natural ordering values for the specified models" - - def add_arguments(self, parser): - parser.add_argument( - 'args', metavar='app_label.ModelName', nargs='*', - help='One or more specific models (each prefixed with its app_label) to retrace', - ) - - def _get_content_types(self, model_names): - q = Q() - for model_name in model_names: - app_label, model = model_name.split('.') - q |= Q(app_label__iexact=app_label, model__iexact=model) - return ContentType.objects.filter(q) - - def handle(self, *model_names, **options): - # Determine the models for which we're retracing all paths - origin_types = self._get_content_types(model_names or ENDPOINT_MODELS) - self.stdout.write(f"Retracing paths for models: {', '.join([str(ct) for ct in origin_types])}") - - # Delete all existing CablePath instances - self.stdout.write(f"Deleting existing cable paths...") - deleted_count, _ = CablePath.objects.filter(origin_type__in=origin_types).delete() - self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths'))) - - # Reset the SQL sequence. Can do this only if deleting _all_ CablePaths. - if not CablePath.objects.count(): - self.stdout.write(f'Resetting database sequence for CablePath...') - sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath]) - with connection.cursor() as cursor: - for sql in sequence_sql: - cursor.execute(sql) - self.stdout.write(self.style.SUCCESS(' Success.')) - - # Retrace interfaces - for ct in origin_types: - model = ct.model_class() - origins = model.objects.filter(cable__isnull=False) - print(f'Retracing {origins.count()} cabled {model._meta.verbose_name_plural}...') - i = 0 - for i, obj in enumerate(origins, start=1): - create_cablepath(obj) - if not i % 1000: - self.stdout.write(f' {i}') - self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}')) - - self.stdout.write(self.style.SUCCESS('Finished.')) diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py new file mode 100644 index 000000000..47636a943 --- /dev/null +++ b/netbox/dcim/management/commands/trace_paths.py @@ -0,0 +1,81 @@ +from django.core.management.base import BaseCommand +from django.core.management.color import no_style +from django.db import connection + +from circuits.models import CircuitTermination +from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort +from dcim.signals import create_cablepath + +ENDPOINT_MODELS = ( + CircuitTermination, + ConsolePort, + ConsoleServerPort, + Interface, + PowerFeed, + PowerOutlet, + PowerPort +) + + +class Command(BaseCommand): + help = "Generate any missing cable paths among all cable termination objects in NetBox" + + def add_arguments(self, parser): + parser.add_argument( + "--force", action='store_true', dest='force', + help="Force recalculation of all existing cable paths" + ) + parser.add_argument( + "--no-input", action='store_true', dest='no_input', + help="Do not prompt user for any input/confirmation" + ) + + def handle(self, *model_names, **options): + + # If --force was passed, first delete all existing CablePaths + if options['force']: + cable_paths = CablePath.objects.all() + paths_count = cable_paths.count() + + # Prompt the user to confirm recalculation of all paths + if paths_count and not options['no_input']: + self.stdout.write(self.style.ERROR("WARNING: Forcing recalculation of all cable paths.")) + self.stdout.write( + f"This will delete and recalculate all {paths_count} existing cable paths. Are you sure?" + ) + confirmation = input("Type yes to confirm: ") + if confirmation != 'yes': + self.stdout.write(self.style.SUCCESS("Aborting")) + return + + # Delete all existing CablePath instances + self.stdout.write(f"Deleting {paths_count} existing cable paths...") + deleted_count, _ = CablePath.objects.all().delete() + self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths'))) + + # Reinitialize the model's PK sequence + self.stdout.write(f'Resetting database sequence for CablePath model') + sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath]) + with connection.cursor() as cursor: + for sql in sequence_sql: + cursor.execute(sql) + + # Retrace paths + for model in ENDPOINT_MODELS: + origins = model.objects.filter(cable__isnull=False) + if not options['force']: + origins = origins.filter(_path__isnull=True) + origins_count = origins.count() + if not origins_count: + print(f'Found no missing {model._meta.verbose_name} paths; skipping') + continue + print(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...') + i = 0 + for i, obj in enumerate(origins, start=1): + create_cablepath(obj) + # TODO: Come up with a better progress indicator + if not i % 1000: + self.stdout.write(f' {i}') + self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}')) + + self.stdout.write(self.style.SUCCESS('Finished.')) diff --git a/upgrade.sh b/upgrade.sh index 66ba7b39f..468f189b3 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -55,6 +55,11 @@ COMMAND="python3 netbox/manage.py migrate" echo "Applying database migrations ($COMMAND)..." eval $COMMAND || exit 1 +# Trace any missing cable paths (not typically needed) +COMMAND="python3 netbox/manage.py trace_paths --no-input" +echo "Checking for missing cable paths ($COMMAND)..." +eval $COMMAND || exit 1 + # Collect static files COMMAND="python3 netbox/manage.py collectstatic --no-input" echo "Collecting static files ($COMMAND)..." From eaf8d95ce54fb9d4b3de239a193792cfc1a255e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Oct 2020 11:14:16 -0400 Subject: [PATCH 120/291] Clean up power utilization logic --- netbox/dcim/models/device_components.py | 13 ++++++++--- netbox/dcim/models/racks.py | 30 ++++++++++++++----------- netbox/templates/dcim/rack.html | 12 ++++++---- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 6e93dd768..72bd453c5 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -320,8 +320,12 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): """ # Calculate aggregate draw of all child power outlets if no numbers have been defined manually if self.allocated_draw is None and self.maximum_draw is None: + poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet) outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) - utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( + utilization = PowerPort.objects.filter( + _cable_peer_type=poweroutlet_ct, + _cable_peer_id__in=outlet_ids + ).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), ) @@ -333,10 +337,13 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): } # Calculate per-leg aggregates for three-phase feeds - if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE: + if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: for leg, leg_name in PowerOutletFeedLegChoices: outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) - utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( + utilization = PowerPort.objects.filter( + _cable_peer_type=poweroutlet_ct, + _cable_peer_id__in=outlet_ids + ).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 409db14c5..78c72f503 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation +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 MaxValueValidator, MinValueValidator @@ -22,6 +23,7 @@ from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.mptt import TreeManager from utilities.utils import array_to_string, serialize_object +from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed @@ -536,20 +538,22 @@ class Rack(ChangeLoggedModel, CustomFieldModel): """ Determine the utilization rate of power in the rack and return it as a percentage. """ - power_stats = PowerFeed.objects.filter( - rack=self - ).annotate( - allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'), - ).values( - 'allocated_draw_total', - 'available_power' - ) + powerfeeds = PowerFeed.objects.filter(rack=self) + available_power_total = sum(pf.available_power for pf in powerfeeds) + if not available_power_total: + return 0 - if power_stats: - allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats) - available_power_total = sum(x['available_power'] for x in power_stats) - return int(allocated_draw_total / available_power_total * 100) or 0 - return 0 + pf_powerports = PowerPort.objects.filter( + _cable_peer_type=ContentType.objects.get_for_model(PowerFeed), + _cable_peer_id__in=powerfeeds.values_list('id', flat=True) + ) + poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports) + allocated_draw_total = PowerPort.objects.filter( + _cable_peer_type=ContentType.objects.get_for_model(PowerOutlet), + _cable_peer_id__in=poweroutlets.values_list('id', flat=True) + ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0 + + return int(allocated_draw_total / available_power_total * 100) @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 4cf3b9018..c44b75be1 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -150,10 +150,14 @@ {{ device_count }} - - Utilization - {% utilization_graph rack.get_utilization %} - + + Space Utilization + {% utilization_graph rack.get_utilization %} + + + Power Utilization + {% utilization_graph rack.get_power_utilization %} +
    From 85439fd952a85bc86c7e38e43f414017fe601e14 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Oct 2020 11:33:47 -0400 Subject: [PATCH 121/291] Fix PowerFeed display in cable traces --- .../templates/dcim/inc/cable_trace_end.html | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/netbox/templates/dcim/inc/cable_trace_end.html b/netbox/templates/dcim/inc/cable_trace_end.html index 6073c06ee..71b7c56ef 100644 --- a/netbox/templates/dcim/inc/cable_trace_end.html +++ b/netbox/templates/dcim/inc/cable_trace_end.html @@ -10,25 +10,34 @@ / {{ end.device.rack }} {% endif %} - {% else %} + {% elif end.circuit %} {{ end.circuit.provider }} + {% elif end.power_panel %} + {{ end.power_panel }}
    + + {{ end.power_panel.site }} + {% endif %}
    {% if end.device %} {# Device component #} {% with model=end|meta:"verbose_name" %} - {{ model|bettertitle }} {{ end }}
    - {% if model == 'interface' %} - {{ end.get_type_display }} - {% elif model == 'front port' or model == 'rear port' %} + {{ model|bettertitle }} {{ end }}
    + {% if model == 'interface' or model == 'front port' or model == 'rear port' %} {{ end.get_type_display }} {% endif %} {% endwith %} - {% else %} - {# Circuit termination #} + {% elif end.circuit %} + {# CircuitTermination #} {{ end.circuit }}
    {{ end }} + {% elif end.power_panel %} + {# PowerFeed #} + Power Feed {{ end }}
    + {% if end.rack %} + {{ end.rack }} + {% endif %} {% endif %}
    From 35759fdb70cdeee50b98e96c9fd297070ad185fa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Oct 2020 16:39:15 -0400 Subject: [PATCH 122/291] Redo the cable trace UI (WIP) --- netbox/templates/dcim/cable_trace.html | 89 +++++++++---------- .../templates/dcim/inc/cable_trace_end.html | 43 --------- netbox/templates/dcim/trace/cable.html | 15 ++++ netbox/templates/dcim/trace/circuit.html | 6 ++ netbox/templates/dcim/trace/device.html | 9 ++ netbox/templates/dcim/trace/powerfeed.html | 9 ++ netbox/templates/dcim/trace/termination.html | 9 ++ 7 files changed, 92 insertions(+), 88 deletions(-) delete mode 100644 netbox/templates/dcim/inc/cable_trace_end.html create mode 100644 netbox/templates/dcim/trace/cable.html create mode 100644 netbox/templates/dcim/trace/circuit.html create mode 100644 netbox/templates/dcim/trace/device.html create mode 100644 netbox/templates/dcim/trace/powerfeed.html create mode 100644 netbox/templates/dcim/trace/termination.html diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 4b3f7f2d4..7df39caca 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -7,66 +7,65 @@ {% block content %}
    -
    - -
    -
    -

    Near End

    -
    -
    - {% if total_length %}
    Total length: {{ total_length|floatformat:"-2" }} Meters
    {% endif %} -
    -
    -

    Far End

    -
    -
    +
    {% for near_end, cable, far_end in path.origin.trace %} + + {# Near end #} + {% if near_end.device %} + {% include 'dcim/trace/device.html' with device=near_end.device %} + {% include 'dcim/trace/termination.html' with termination=near_end %} + {% elif near_end.power_panel %} + {% include 'dcim/trace/powerfeed.html' with powerfeed=near_end %} + {% elif near_end.circuit %} + {% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %} + {% include 'dcim/trace/termination.html' with termination=near_end %} + {% endif %} + + {# Cable #}
    -
    -

    {{ forloop.counter }}

    -
    -
    - {% include 'dcim/inc/cable_trace_end.html' with end=near_end %} -
    -
    - {% if cable %} -

    - - {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} - -

    -

    {{ cable.get_status_display }}

    -

    {{ cable.get_type_display|default:"" }}

    - {% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %} - {% if cable.color %} -   - {% endif %} - {% else %} -

    No Cable

    - {% endif %} -
    -
    - {% if far_end %} - {% include 'dcim/inc/cable_trace_end.html' with end=far_end %} - {% endif %} -
    + {% if cable %} + {% include 'dcim/trace/cable.html' %} + {% else %} +

    No cable

    + {% endif %}
    -
    + + {# Far end #} + {% if far_end.device %} + {% include 'dcim/trace/termination.html' with termination=far_end %} + {% if forloop.last %} + {% include 'dcim/trace/device.html' with device=far_end.device %} + {% endif %} + {% elif far_end.power_panel %} + {% include 'dcim/trace/powerfeed.html' with powerfeed=far_end %} + {% elif far_end.circuit %} + {% include 'dcim/trace/termination.html' with termination=far_end %} + {% if forloop.last %} + {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %} + {% endif %} + {% endif %} + {% endfor %}
    -
    +

    Trace completed!

    + {% if total_length %} +
    Total length: {{ total_length|floatformat:"-2" }} Meters
    + {% endif %}
    -
    +

    Related Paths

    diff --git a/netbox/templates/dcim/inc/cable_trace_end.html b/netbox/templates/dcim/inc/cable_trace_end.html deleted file mode 100644 index 71b7c56ef..000000000 --- a/netbox/templates/dcim/inc/cable_trace_end.html +++ /dev/null @@ -1,43 +0,0 @@ -{% load helpers %} - -
    -
    - {% if end.device %} - {{ end.device }}
    - - {{ end.device.site }} - {% if end.device.rack %} - / {{ end.device.rack }} - {% endif %} - - {% elif end.circuit %} - {{ end.circuit.provider }} - {% elif end.power_panel %} - {{ end.power_panel }}
    - - {{ end.power_panel.site }} - - {% endif %} -
    -
    - {% if end.device %} - {# Device component #} - {% with model=end|meta:"verbose_name" %} - {{ model|bettertitle }} {{ end }}
    - {% if model == 'interface' or model == 'front port' or model == 'rear port' %} - {{ end.get_type_display }} - {% endif %} - {% endwith %} - {% elif end.circuit %} - {# CircuitTermination #} - {{ end.circuit }}
    - {{ end }} - {% elif end.power_panel %} - {# PowerFeed #} - Power Feed {{ end }}
    - {% if end.rack %} - {{ end.rack }} - {% endif %} - {% endif %} -
    -
    diff --git a/netbox/templates/dcim/trace/cable.html b/netbox/templates/dcim/trace/cable.html new file mode 100644 index 000000000..2cb5ffed6 --- /dev/null +++ b/netbox/templates/dcim/trace/cable.html @@ -0,0 +1,15 @@ +
    +

    +

    + + {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} + +

    +

    {{ cable.get_status_display }}

    +

    {{ cable.get_type_display|default:"" }}

    + {% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %} + {% if cable.color %} +   + {% endif %} +

    +
    diff --git a/netbox/templates/dcim/trace/circuit.html b/netbox/templates/dcim/trace/circuit.html new file mode 100644 index 000000000..ef1ed05bc --- /dev/null +++ b/netbox/templates/dcim/trace/circuit.html @@ -0,0 +1,6 @@ + diff --git a/netbox/templates/dcim/trace/device.html b/netbox/templates/dcim/trace/device.html new file mode 100644 index 000000000..c3ed109d7 --- /dev/null +++ b/netbox/templates/dcim/trace/device.html @@ -0,0 +1,9 @@ +
    +
    + Device {{ device }}
    + {{ device.site }} + {% if device.rack %} + / {{ device.rack }} + {% endif %} +
    +
    diff --git a/netbox/templates/dcim/trace/powerfeed.html b/netbox/templates/dcim/trace/powerfeed.html new file mode 100644 index 000000000..a439aff27 --- /dev/null +++ b/netbox/templates/dcim/trace/powerfeed.html @@ -0,0 +1,9 @@ +
    +
    + Power Feed {{ powerfeed }}
    + {{ powerfeed.power_panel }} + {% if powerfeed.rack %} + / {{ powerfeed.rack }} + {% endif %} +
    +
    diff --git a/netbox/templates/dcim/trace/termination.html b/netbox/templates/dcim/trace/termination.html new file mode 100644 index 000000000..dedb562ea --- /dev/null +++ b/netbox/templates/dcim/trace/termination.html @@ -0,0 +1,9 @@ +{% load helpers %} +
    +
    + {{ termination|meta:"verbose_name"|bettertitle }} {{ termination }} + {% if termination.type %} +
    {{ termination.get_type_display }} + {% endif %} +
    +
    \ No newline at end of file From aa0ee2720bb34615b9ec59c0282f285133143be6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 10:32:17 -0400 Subject: [PATCH 123/291] Add cable paths API detail view for pass-through ports --- netbox/dcim/api/serializers.py | 47 +++++++++++++++++++++++++++++++++- netbox/dcim/api/views.py | 20 ++++++++++++--- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 11e286132..d599e7461 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,12 +7,13 @@ from rest_framework.validators import UniqueTogetherValidator from dcim.choices import * from dcim.constants import * from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) +from dcim.utils import decompile_path_node from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -734,6 +735,50 @@ class TracedCableSerializer(serializers.ModelSerializer): ] +class CablePathSerializer(serializers.ModelSerializer): + origin_type = ContentTypeField(read_only=True) + origin = serializers.SerializerMethodField(read_only=True) + destination_type = ContentTypeField(read_only=True) + destination = serializers.SerializerMethodField(read_only=True) + path = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CablePath + fields = [ + 'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_origin(self, obj): + """ + Return the appropriate serializer for the origin. + """ + serializer = get_serializer_for_model(obj.origin, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.origin, context=context).data + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_destination(self, obj): + """ + Return the appropriate serializer for the destination, if any. + """ + if obj.destination_id is not None: + serializer = get_serializer_for_model(obj.destination, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.destination, context=context).data + return None + + @swagger_serializer_method(serializer_or_field=serializers.ListField) + def get_path(self, obj): + ret = [] + for node in obj.path: + ct_id, object_id = decompile_path_node(node) + ct = ContentType.objects.get_for_id(ct_id) + # TODO: Return the object URL + ret.append(f'{ct.app_label}.{ct.model}:{object_id}') + return ret + + # # Interface connections # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 14d6177bb..50dc82c9d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -17,7 +17,7 @@ from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit from dcim import filters from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, @@ -77,6 +77,20 @@ class PathEndpointMixin(object): return Response(path) +class PassThroughPortMixin(object): + + @action(detail=True, url_path='paths') + def paths(self, request, pk): + """ + Return all CablePaths which traverse a given pass-through port. + """ + obj = get_object_or_404(self.queryset, pk=pk) + cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination') + serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True) + + return Response(serializer.data) + + # # Regions # @@ -503,13 +517,13 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet): filterset_class = filters.InterfaceFilterSet -class FrontPortViewSet(ModelViewSet): +class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') serializer_class = serializers.FrontPortSerializer filterset_class = filters.FrontPortFilterSet -class RearPortViewSet(ModelViewSet): +class RearPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') serializer_class = serializers.RearPortSerializer filterset_class = filters.RearPortFilterSet From 55268c90c8173088607b62bdf1d08935296c4e04 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 11:15:09 -0400 Subject: [PATCH 124/291] Replace connection_status with connected_endpoint_reachable on InterfaceConnectionSerializer --- netbox/dcim/api/serializers.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d599e7461..f9e17b12c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -786,17 +786,23 @@ class CablePathSerializer(serializers.ModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer): interface_a = serializers.SerializerMethodField() interface_b = NestedInterfaceSerializer(source='connected_endpoint') - # connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) + connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) class Meta: model = Interface - fields = ['interface_a', 'interface_b'] + fields = ['interface_a', 'interface_b', 'connected_endpoint_reachable'] @swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer) def get_interface_a(self, obj): context = {'request': self.context['request']} return NestedInterfaceSerializer(instance=obj, context=context).data + @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + def get_connected_endpoint_reachable(self, obj): + if obj._path is not None: + return obj._path.is_active + return None + # # Virtual chassis From ae1ceb26b92809176a9cd3be6ea704a61eed15b4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 11:23:24 -0400 Subject: [PATCH 125/291] Standardize cable/connection field ordering --- netbox/circuits/api/serializers.py | 4 ++-- netbox/dcim/api/serializers.py | 31 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 9bc95f065..ad5e609e4 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -77,6 +77,6 @@ class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpoint model = CircuitTermination fields = [ 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', - 'cable_peer', 'cable_peer_type', + 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable' ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f9e17b12c..d6da5a5e3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -487,8 +487,8 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial class Meta: model = ConsoleServerPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'cable_peer_type', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', ] @@ -505,8 +505,8 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, class Meta: model = ConsolePort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', - 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', 'cable_peer_type', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', ] @@ -533,9 +533,9 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', - 'cable_peer_type', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', + 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', ] @@ -552,9 +552,9 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co class Meta: model = PowerPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', - 'cable_peer_type', 'tags', + 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', + 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', ] @@ -578,8 +578,9 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co model = Interface fields = [ 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', - 'cable_peer', 'cable_peer_type', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'count_ipaddresses', ] # TODO: This validation should be handled by Interface.clean() @@ -872,7 +873,7 @@ class PowerFeedSerializer( model = PowerFeed fields = [ 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'connected_endpoint_type', 'connected_endpoint', 'connected_endpoint_reachable', 'cable', 'cable_peer', - 'cable_peer_type', + 'max_utilization', 'comments', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', ] From 29eebf9fbe43321a43597fff1f2d99cf6766ed53 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 11:26:02 -0400 Subject: [PATCH 126/291] Update REST API changes --- docs/release-notes/version-2.10.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index fa3f3aea8..33c3bd1b6 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -68,27 +68,32 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * dcim.ConsolePort: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.ConsoleServerPort: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.FrontPort: - * Removed the `/trace/` endpoint + * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths * Added `cable_peer` and `cable_peer_type` * dcim.Interface: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning * dcim.PowerFeed: Add fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type` * dcim.PowerOutlet: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.PowerPanel: Added `custom_fields` * dcim.PowerPort * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` + * Removed `connection_status` from nested serializer * dcim.RackReservation: Added `custom_fields` * dcim.RearPort: - * Removed the `/trace/` endpoint + * Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths * Added `cable_peer` and `cable_peer_type` * dcim.VirtualChassis: Added `custom_fields` * extras.ExportTemplate: The `template_language` field has been removed From 0c5efa243defd2695c6b2e884bbe4d48c3fa69fb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 13:45:47 -0400 Subject: [PATCH 127/291] Handle traces which split at a RearPort --- netbox/dcim/exceptions.py | 9 ---- netbox/dcim/models/device_components.py | 8 ++-- netbox/dcim/tests/test_cablepaths.py | 57 +++++++++++++++++++++++++ netbox/dcim/utils.py | 5 +-- netbox/templates/dcim/cable_trace.html | 32 ++++++++------ 5 files changed, 82 insertions(+), 29 deletions(-) diff --git a/netbox/dcim/exceptions.py b/netbox/dcim/exceptions.py index 18e42318b..e788c9b5f 100644 --- a/netbox/dcim/exceptions.py +++ b/netbox/dcim/exceptions.py @@ -3,12 +3,3 @@ class LoopDetected(Exception): A loop has been detected while tracing a cable path. """ pass - - -class CableTraceSplit(Exception): - """ - A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and - we don't know which one to follow. - """ - def __init__(self, termination, *args, **kwargs): - self.termination = termination diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 72bd453c5..2ea9b88b6 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -172,9 +172,11 @@ class PathEndpoint(models.Model): return [] # Construct the complete path - path = [self, *[path_node_to_object(obj) for obj in self._path.path], self._path.destination] - assert not len(path) % 3,\ - f"Invalid path length for CablePath #{self.pk}: {len(self._path.path)} elements in path" + path = [self, *[path_node_to_object(obj) for obj in self._path.path]] + while (len(path) + 1) % 3: + # Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort) + path.append(None) + path.append(self._path.destination) # Return the path as a list of three-tuples (A termination, cable, B termination) return list(zip(*[iter(path)] * 3)) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index cfe63929d..95a8e0d30 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -721,6 +721,63 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + def test_206_unidirectional_split_paths(self): + """ + [IF1] --C1-- [RP1] [FP1:1] --C2-- [IF2] + [FP1:2] --C3-- [IF3] + """ + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.interface3.refresh_from_db() + + # Create cables 1 + cable1 = Cable(termination_a=self.interface1, termination_b=self.rear_port1) + cable1.save() + self.assertPathExists( + origin=self.interface1, + destination=None, + path=(cable1, self.rear_port1), + is_active=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cables 2-3 + cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_1) + cable2.save() + cable3 = Cable(termination_a=self.interface3, termination_b=self.front_port1_2) + cable3.save() + self.assertPathExists( + origin=self.interface2, + destination=self.interface1, + path=(cable2, self.front_port1_1, self.rear_port1, cable1), + is_active=True + ) + self.assertPathExists( + origin=self.interface3, + destination=self.interface1, + path=(cable3, self.front_port1_2, self.rear_port1, cable1), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + + # Delete cable 1 + cable1.delete() + + # Check that the partial path was deleted and the two complete paths are now partial + self.assertPathExists( + origin=self.interface2, + destination=None, + path=(cable2, self.front_port1_1, self.rear_port1), + is_active=False + ) + self.assertPathExists( + origin=self.interface3, + destination=None, + path=(cable3, self.front_port1_2, self.rear_port1), + is_active=False + ) + self.assertEqual(CablePath.objects.count(), 2) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 52b0a4232..b82dd58d2 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,7 +1,6 @@ from django.contrib.contenttypes.models import ContentType from .choices import CableStatusChoices -from .exceptions import CableTraceSplit def compile_path_node(ct_id, object_id): @@ -69,8 +68,8 @@ def trace_path(node): node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position) path.append(object_to_path_node(node)) else: - # No position indicated: path has split (probably invalid?) - raise CableTraceSplit(peer_termination) + # No position indicated: path has split, so we stop at the RearPort + break # Anything else marks the end of the path else: diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 7df39caca..a328f2052 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -19,16 +19,17 @@ {% elif near_end.circuit %} {% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %} {% include 'dcim/trace/termination.html' with termination=near_end %} + {% else %} +

    Split Paths!

    + {# TODO: Present the user with successive paths to choose from #} {% endif %} {# Cable #} -
    - {% if cable %} + {% if cable %} +
    {% include 'dcim/trace/cable.html' %} - {% else %} -

    No cable

    - {% endif %} -
    +
    + {% endif %} {# Far end #} {% if far_end.device %} @@ -45,15 +46,18 @@ {% endif %} {% endif %} + {% if forloop.last and far_end %} +
    +
    +

    Trace completed!

    + {% if total_length %} +
    Total length: {{ total_length|floatformat:"-2" }} Meters
    + {% endif %} +
    +
    + {% endif %} + {% endfor %} -
    -
    -

    Trace completed!

    - {% if total_length %} -
    Total length: {{ total_length|floatformat:"-2" }} Meters
    - {% endif %} -
    -
    From 0e41bc48b7a9a248862c3bbda0ba99f11b8334bd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 13:55:29 -0400 Subject: [PATCH 128/291] Add /trace API endpoints for CircuitTermination and PowerFeed --- docs/release-notes/version-2.10.md | 5 ++++- netbox/circuits/api/views.py | 3 ++- netbox/dcim/api/views.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 33c3bd1b6..0e636fc45 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -62,6 +62,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete) * circuits.CircuitTermination: + * Added the `/trace/` endpoint * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` * dcim.Cable: Added `custom_fields` @@ -81,7 +82,9 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * Added `cable_peer` and `cable_peer_type` * Removed `connection_status` from nested serializer * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning -* dcim.PowerFeed: Add fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type` +* dcim.PowerFeed: + * Added the `/trace/` endpoint + * Added fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type` * dcim.PowerOutlet: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 7b147412e..516831983 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,6 +3,7 @@ from rest_framework.routers import APIRootView from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit +from dcim.api.views import PathEndpointMixin from extras.api.views import CustomFieldModelViewSet from utilities.api import ModelViewSet from . import serializers @@ -57,7 +58,7 @@ class CircuitViewSet(CustomFieldModelViewSet): # Circuit Terminations # -class CircuitTerminationViewSet(ModelViewSet): +class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): queryset = CircuitTermination.objects.prefetch_related( 'circuit', 'site', '_path__destination', 'cable' ) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 50dc82c9d..c45879dbe 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -614,7 +614,7 @@ class PowerPanelViewSet(ModelViewSet): # Power feeds # -class PowerFeedViewSet(CustomFieldModelViewSet): +class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): queryset = PowerFeed.objects.prefetch_related( 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags' ) From 75ddc6346637f335001fbecded8f7baf41d95d6a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 14:01:47 -0400 Subject: [PATCH 129/291] Handle split paths --- netbox/dcim/api/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c45879dbe..b14c67e65 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -58,6 +58,9 @@ class PathEndpointMixin(object): path = [] for near_end, cable, far_end in obj.trace(): + if near_end is None: + # Split paths + break # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') From a716ca705c4f4911718d50af6b7953bf24631439 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 14:55:13 -0400 Subject: [PATCH 130/291] Rewrite cablepath tests to create components within each test --- netbox/dcim/tests/test_cablepaths.py | 700 ++++++++++++++------------- 1 file changed, 363 insertions(+), 337 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 95a8e0d30..5699b3b88 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -20,108 +20,18 @@ class CablePathTestCase(TestCase): def setUpTestData(cls): # Create a single device that will hold all components - site = Site.objects.create(name='Site', slug='site') + cls.site = Site.objects.create(name='Site', slug='site') + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') - device = Device.objects.create(site=site, device_type=device_type, device_role=device_role, name='Test Device') + cls.device = Device.objects.create(site=cls.site, device_type=device_type, device_role=device_role, name='Test Device') - # Create console/power components for testing - cls.consoleport1 = ConsolePort.objects.create(device=device, name='Console Port 1') - cls.consoleserverport1 = ConsoleServerPort.objects.create(device=device, name='Console Server Port 1') - cls.powerport1 = PowerPort.objects.create(device=device, name='Power Port 1') - cls.poweroutlet1 = PowerPort.objects.create(device=device, name='Power Outlet 1') + cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel') - # Create 4 interfaces for testing - cls.interface1 = Interface(device=device, name=f'Interface 1') - cls.interface2 = Interface(device=device, name=f'Interface 2') - cls.interface3 = Interface(device=device, name=f'Interface 3') - cls.interface4 = Interface(device=device, name=f'Interface 4') - Interface.objects.bulk_create([ - cls.interface1, - cls.interface2, - cls.interface3, - cls.interface4 - ]) - - # Create four RearPorts with four positions each, and two with only one position - cls.rear_port1 = RearPort(device=device, name=f'RP1', positions=4) - cls.rear_port2 = RearPort(device=device, name=f'RP2', positions=4) - cls.rear_port3 = RearPort(device=device, name=f'RP3', positions=4) - cls.rear_port4 = RearPort(device=device, name=f'RP4', positions=4) - cls.rear_port5 = RearPort(device=device, name=f'RP5', positions=1) - cls.rear_port6 = RearPort(device=device, name=f'RP6', positions=1) - RearPort.objects.bulk_create([ - cls.rear_port1, - cls.rear_port2, - cls.rear_port3, - cls.rear_port4, - cls.rear_port5, - cls.rear_port6 - ]) - - # Create FrontPorts to match RearPorts (4x4 + 2x1) - cls.front_port1_1 = FrontPort(device=device, name=f'FP1:1', rear_port=cls.rear_port1, rear_port_position=1) - cls.front_port1_2 = FrontPort(device=device, name=f'FP1:2', rear_port=cls.rear_port1, rear_port_position=2) - cls.front_port1_3 = FrontPort(device=device, name=f'FP1:3', rear_port=cls.rear_port1, rear_port_position=3) - cls.front_port1_4 = FrontPort(device=device, name=f'FP1:4', rear_port=cls.rear_port1, rear_port_position=4) - cls.front_port2_1 = FrontPort(device=device, name=f'FP2:1', rear_port=cls.rear_port2, rear_port_position=1) - cls.front_port2_2 = FrontPort(device=device, name=f'FP2:2', rear_port=cls.rear_port2, rear_port_position=2) - cls.front_port2_3 = FrontPort(device=device, name=f'FP2:3', rear_port=cls.rear_port2, rear_port_position=3) - cls.front_port2_4 = FrontPort(device=device, name=f'FP2:4', rear_port=cls.rear_port2, rear_port_position=4) - cls.front_port3_1 = FrontPort(device=device, name=f'FP3:1', rear_port=cls.rear_port3, rear_port_position=1) - cls.front_port3_2 = FrontPort(device=device, name=f'FP3:2', rear_port=cls.rear_port3, rear_port_position=2) - cls.front_port3_3 = FrontPort(device=device, name=f'FP3:3', rear_port=cls.rear_port3, rear_port_position=3) - cls.front_port3_4 = FrontPort(device=device, name=f'FP3:4', rear_port=cls.rear_port3, rear_port_position=4) - cls.front_port4_1 = FrontPort(device=device, name=f'FP4:1', rear_port=cls.rear_port4, rear_port_position=1) - cls.front_port4_2 = FrontPort(device=device, name=f'FP4:2', rear_port=cls.rear_port4, rear_port_position=2) - cls.front_port4_3 = FrontPort(device=device, name=f'FP4:3', rear_port=cls.rear_port4, rear_port_position=3) - cls.front_port4_4 = FrontPort(device=device, name=f'FP4:4', rear_port=cls.rear_port4, rear_port_position=4) - cls.front_port5_1 = FrontPort(device=device, name=f'FP5:1', rear_port=cls.rear_port5, rear_port_position=1) - cls.front_port6_1 = FrontPort(device=device, name=f'FP6:1', rear_port=cls.rear_port6, rear_port_position=1) - FrontPort.objects.bulk_create([ - cls.front_port1_1, - cls.front_port1_2, - cls.front_port1_3, - cls.front_port1_4, - cls.front_port2_1, - cls.front_port2_2, - cls.front_port2_3, - cls.front_port2_4, - cls.front_port3_1, - cls.front_port3_2, - cls.front_port3_3, - cls.front_port3_4, - cls.front_port4_1, - cls.front_port4_2, - cls.front_port4_3, - cls.front_port4_4, - cls.front_port5_1, - cls.front_port6_1, - ]) - - # Create a PowerFeed for testing - powerpanel = PowerPanel.objects.create(site=site, name='Power Panel') - cls.powerfeed1 = PowerFeed.objects.create(power_panel=powerpanel, name='Power Feed 1') - - # Create four CircuitTerminations for testing provider = Provider.objects.create(name='Provider', slug='provider') circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') - circuits = [ - Circuit(provider=provider, type=circuit_type, cid='Circuit 1'), - Circuit(provider=provider, type=circuit_type, cid='Circuit 2'), - ] - Circuit.objects.bulk_create(circuits) - cls.circuittermination1_A = CircuitTermination(circuit=circuits[0], site=site, term_side='A', port_speed=1000) - cls.circuittermination1_Z = CircuitTermination(circuit=circuits[0], site=site, term_side='Z', port_speed=1000) - cls.circuittermination2_A = CircuitTermination(circuit=circuits[1], site=site, term_side='A', port_speed=1000) - cls.circuittermination2_Z = CircuitTermination(circuit=circuits[1], site=site, term_side='Z', port_speed=1000) - CircuitTermination.objects.bulk_create([ - cls.circuittermination1_A, - cls.circuittermination1_Z, - cls.circuittermination2_A, - cls.circuittermination2_Z, - ]) + cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') def assertPathExists(self, origin, destination, path=None, is_active=None, msg=None): """ @@ -187,26 +97,29 @@ class CablePathTestCase(TestCase): """ [IF1] --C1-- [IF2] """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + # Create cable 1 - cable1 = Cable(termination_a=self.interface1, termination_b=self.interface2) + cable1 = Cable(termination_a=interface1, termination_b=interface2) cable1.save() path1 = self.assertPathExists( - origin=self.interface1, - destination=self.interface2, + origin=interface1, + destination=interface2, path=(cable1,), is_active=True ) path2 = self.assertPathExists( - origin=self.interface2, - destination=self.interface1, + origin=interface2, + destination=interface1, path=(cable1,), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.assertPathIsSet(self.interface1, path1) - self.assertPathIsSet(self.interface2, path2) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) # Delete cable 1 cable1.delete() @@ -218,26 +131,29 @@ class CablePathTestCase(TestCase): """ [CP1] --C1-- [CSP1] """ + consoleport1 = ConsolePort.objects.create(device=self.device, name='Console Port 1') + consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Console Server Port 1') + # Create cable 1 - cable1 = Cable(termination_a=self.consoleport1, termination_b=self.consoleserverport1) + cable1 = Cable(termination_a=consoleport1, termination_b=consoleserverport1) cable1.save() path1 = self.assertPathExists( - origin=self.consoleport1, - destination=self.consoleserverport1, + origin=consoleport1, + destination=consoleserverport1, path=(cable1,), is_active=True ) path2 = self.assertPathExists( - origin=self.consoleserverport1, - destination=self.consoleport1, + origin=consoleserverport1, + destination=consoleport1, path=(cable1,), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) - self.consoleport1.refresh_from_db() - self.consoleserverport1.refresh_from_db() - self.assertPathIsSet(self.consoleport1, path1) - self.assertPathIsSet(self.consoleserverport1, path2) + consoleport1.refresh_from_db() + consoleserverport1.refresh_from_db() + self.assertPathIsSet(consoleport1, path1) + self.assertPathIsSet(consoleserverport1, path2) # Delete cable 1 cable1.delete() @@ -249,26 +165,29 @@ class CablePathTestCase(TestCase): """ [PP1] --C1-- [PO1] """ + powerport1 = PowerPort.objects.create(device=self.device, name='Power Port 1') + poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Power Outlet 1') + # Create cable 1 - cable1 = Cable(termination_a=self.powerport1, termination_b=self.poweroutlet1) + cable1 = Cable(termination_a=powerport1, termination_b=poweroutlet1) cable1.save() path1 = self.assertPathExists( - origin=self.powerport1, - destination=self.poweroutlet1, + origin=powerport1, + destination=poweroutlet1, path=(cable1,), is_active=True ) path2 = self.assertPathExists( - origin=self.poweroutlet1, - destination=self.powerport1, + origin=poweroutlet1, + destination=powerport1, path=(cable1,), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) - self.powerport1.refresh_from_db() - self.poweroutlet1.refresh_from_db() - self.assertPathIsSet(self.powerport1, path1) - self.assertPathIsSet(self.poweroutlet1, path2) + powerport1.refresh_from_db() + poweroutlet1.refresh_from_db() + self.assertPathIsSet(powerport1, path1) + self.assertPathIsSet(poweroutlet1, path2) # Delete cable 1 cable1.delete() @@ -280,26 +199,29 @@ class CablePathTestCase(TestCase): """ [PP1] --C1-- [PF1] """ + powerport1 = PowerPort.objects.create(device=self.device, name='Power Port 1') + powerfeed1 = PowerFeed.objects.create(power_panel=self.powerpanel, name='Power Feed 1') + # Create cable 1 - cable1 = Cable(termination_a=self.powerport1, termination_b=self.powerfeed1) + cable1 = Cable(termination_a=powerport1, termination_b=powerfeed1) cable1.save() path1 = self.assertPathExists( - origin=self.powerport1, - destination=self.powerfeed1, + origin=powerport1, + destination=powerfeed1, path=(cable1,), is_active=True ) path2 = self.assertPathExists( - origin=self.powerfeed1, - destination=self.powerport1, + origin=powerfeed1, + destination=powerport1, path=(cable1,), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) - self.powerport1.refresh_from_db() - self.powerfeed1.refresh_from_db() - self.assertPathIsSet(self.powerport1, path1) - self.assertPathIsSet(self.powerfeed1, path2) + powerport1.refresh_from_db() + powerfeed1.refresh_from_db() + self.assertPathIsSet(powerport1, path1) + self.assertPathIsSet(powerfeed1, path2) # Delete cable 1 cable1.delete() @@ -309,28 +231,33 @@ class CablePathTestCase(TestCase): def test_105_interface_to_circuittermination(self): """ - [PP1] --C1-- [CT1A] + [IF1] --C1-- [CT1A] """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + circuittermination1 = CircuitTermination.objects.create( + circuit=self.circuit, site=self.site, term_side='A', port_speed=1000 + ) + # Create cable 1 - cable1 = Cable(termination_a=self.interface1, termination_b=self.circuittermination1_A) + cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) cable1.save() path1 = self.assertPathExists( - origin=self.interface1, - destination=self.circuittermination1_A, + origin=interface1, + destination=circuittermination1, path=(cable1,), is_active=True ) path2 = self.assertPathExists( - origin=self.circuittermination1_A, - destination=self.interface1, + origin=circuittermination1, + destination=interface1, path=(cable1,), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) - self.interface1.refresh_from_db() - self.circuittermination1_A.refresh_from_db() - self.assertPathIsSet(self.interface1, path1) - self.assertPathIsSet(self.circuittermination1_A, path2) + interface1.refresh_from_db() + circuittermination1.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(circuittermination1, path2) # Delete cable 1 cable1.delete() @@ -340,35 +267,39 @@ class CablePathTestCase(TestCase): def test_201_single_path_via_pass_through(self): """ - [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] + [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) # Create cable 1 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) + cable1 = Cable(termination_a=interface1, termination_b=frontport1) cable1.save() self.assertPathExists( - origin=self.interface1, + origin=interface1, destination=None, - path=(cable1, self.front_port5_1, self.rear_port5), + path=(cable1, frontport1, rearport1), is_active=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 2 - cable2 = Cable(termination_a=self.rear_port5, termination_b=self.interface2) + cable2 = Cable(termination_a=rearport1, termination_b=interface2) cable2.save() self.assertPathExists( - origin=self.interface1, - destination=self.interface2, - path=(cable1, self.front_port5_1, self.rear_port5, cable2), + origin=interface1, + destination=interface2, + path=(cable1, frontport1, rearport1, cable2), is_active=True ) self.assertPathExists( - origin=self.interface2, - destination=self.interface1, - path=(cable2, self.rear_port5, self.front_port5_1, cable1), + origin=interface2, + destination=interface1, + path=(cable2, rearport1, frontport1, cable1), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -376,100 +307,114 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() path1 = self.assertPathExists( - origin=self.interface1, + origin=interface1, destination=None, - path=(cable1, self.front_port5_1, self.rear_port5), + path=(cable1, frontport1, rearport1), is_active=False ) self.assertEqual(CablePath.objects.count(), 1) - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.assertPathIsSet(self.interface1, path1) - self.assertPathIsNotSet(self.interface2) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsNotSet(interface2) def test_202_multiple_paths_via_pass_through(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.interface3.refresh_from_db() - self.interface4.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) # Create cables 1-2 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) + cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) cable1.save() - cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) + cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) cable2.save() self.assertPathExists( - origin=self.interface1, + origin=interface1, destination=None, - path=(cable1, self.front_port1_1, self.rear_port1), + path=(cable1, frontport1_1, rearport1), is_active=False ) self.assertPathExists( - origin=self.interface2, + origin=interface2, destination=None, - path=(cable2, self.front_port1_2, self.rear_port1), + path=(cable2, frontport1_2, rearport1), is_active=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cable 3 - cable3 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) + cable3 = Cable(termination_a=rearport1, termination_b=rearport2) cable3.save() self.assertPathExists( - origin=self.interface1, + origin=interface1, destination=None, - path=(cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1), + path=(cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1), is_active=False ) self.assertPathExists( - origin=self.interface2, + origin=interface2, destination=None, - path=(cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2), + path=(cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2), is_active=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cables 4-5 - cable4 = Cable(termination_a=self.front_port2_1, termination_b=self.interface3) + cable4 = Cable(termination_a=frontport2_1, termination_b=interface3) cable4.save() - cable5 = Cable(termination_a=self.front_port2_2, termination_b=self.interface4) + cable5 = Cable(termination_a=frontport2_2, termination_b=interface4) cable5.save() path1 = self.assertPathExists( - origin=self.interface1, - destination=self.interface3, + origin=interface1, + destination=interface3, path=( - cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1, + cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, ), is_active=True ) path2 = self.assertPathExists( - origin=self.interface2, - destination=self.interface4, + origin=interface2, + destination=interface4, path=( - cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2, + cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, ), is_active=True ) path3 = self.assertPathExists( - origin=self.interface3, - destination=self.interface1, + origin=interface3, + destination=interface1, path=( - cable4, self.front_port2_1, self.rear_port2, cable3, self.rear_port1, self.front_port1_1, + cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1 ), is_active=True ) path4 = self.assertPathExists( - origin=self.interface4, - destination=self.interface2, + origin=interface4, + destination=interface2, path=( - cable5, self.front_port2_2, self.rear_port2, cable3, self.rear_port1, self.front_port1_2, + cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2 ), is_active=True @@ -482,82 +427,104 @@ class CablePathTestCase(TestCase): # Check for four partial paths; one from each interface self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.interface3.refresh_from_db() - self.interface4.refresh_from_db() - self.assertPathIsSet(self.interface1, path1) - self.assertPathIsSet(self.interface2, path2) - self.assertPathIsSet(self.interface3, path3) - self.assertPathIsSet(self.interface4, path4) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + self.assertPathIsSet(interface3, path3) + self.assertPathIsSet(interface4, path4) def test_203_multiple_paths_via_nested_pass_throughs(self): """ - [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2:1] [RP2] --C4-- [RP3] [FP3:1] --C5-- [RP4] [FP4:1] --C6-- [IF3] - [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] + [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3] --C5-- [RP4] [FP4:1] --C6-- [IF3] + [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.interface3.refresh_from_db() - self.interface4.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4_1 = FrontPort.objects.create( + device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1 + ) + frontport4_2 = FrontPort.objects.create( + device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2 + ) # Create cables 1-2, 6-7 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) + cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) cable1.save() - cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) + cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) cable2.save() - cable6 = Cable(termination_a=self.interface3, termination_b=self.front_port4_1) + cable6 = Cable(termination_a=interface3, termination_b=frontport4_1) cable6.save() - cable7 = Cable(termination_a=self.interface4, termination_b=self.front_port4_2) + cable7 = Cable(termination_a=interface4, termination_b=frontport4_2) cable7.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3 and 5 - cable3 = Cable(termination_a=self.rear_port1, termination_b=self.front_port2_1) + cable3 = Cable(termination_a=rearport1, termination_b=frontport2) cable3.save() - cable5 = Cable(termination_a=self.rear_port4, termination_b=self.front_port3_1) + cable5 = Cable(termination_a=rearport4, termination_b=frontport3) cable5.save() self.assertEqual(CablePath.objects.count(), 4) # Four (longer) partial paths; one from each interface # Create cable 4 - cable4 = Cable(termination_a=self.rear_port2, termination_b=self.rear_port3) + cable4 = Cable(termination_a=rearport2, termination_b=rearport3) cable4.save() self.assertPathExists( - origin=self.interface1, - destination=self.interface3, + origin=interface1, + destination=interface3, path=( - cable1, self.front_port1_1, self.rear_port1, cable3, self.front_port2_1, self.rear_port2, - cable4, self.rear_port3, self.front_port3_1, cable5, self.rear_port4, self.front_port4_1, + cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3, cable5, rearport4, frontport4_1, cable6 ), is_active=True ) self.assertPathExists( - origin=self.interface2, - destination=self.interface4, + origin=interface2, + destination=interface4, path=( - cable2, self.front_port1_2, self.rear_port1, cable3, self.front_port2_1, self.rear_port2, - cable4, self.rear_port3, self.front_port3_1, cable5, self.rear_port4, self.front_port4_2, + cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3, cable5, rearport4, frontport4_2, cable7 ), is_active=True ) self.assertPathExists( - origin=self.interface3, - destination=self.interface1, + origin=interface3, + destination=interface1, path=( - cable6, self.front_port4_1, self.rear_port4, cable5, self.front_port3_1, self.rear_port3, - cable4, self.rear_port2, self.front_port2_1, cable3, self.rear_port1, self.front_port1_1, + cable6, frontport4_1, rearport4, cable5, frontport3, rearport3, + cable4, rearport2, frontport2, cable3, rearport1, frontport1_1, cable1 ), is_active=True ) self.assertPathExists( - origin=self.interface4, - destination=self.interface2, + origin=interface4, + destination=interface2, path=( - cable7, self.front_port4_2, self.rear_port4, cable5, self.front_port3_1, self.rear_port3, - cable4, self.rear_port2, self.front_port2_1, cable3, self.rear_port1, self.front_port1_2, + cable7, frontport4_2, rearport4, cable5, frontport3, rearport3, + cable4, rearport2, frontport2, cable3, rearport1, frontport1_2, cable2 ), is_active=True @@ -576,67 +543,95 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3] [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.interface3.refresh_from_db() - self.interface4.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + frontport3_1 = FrontPort.objects.create( + device=self.device, name='Front Port 3:1', rear_port=rearport3, rear_port_position=1 + ) + frontport3_2 = FrontPort.objects.create( + device=self.device, name='Front Port 3:2', rear_port=rearport3, rear_port_position=2 + ) + frontport4_1 = FrontPort.objects.create( + device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1 + ) + frontport4_2 = FrontPort.objects.create( + device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2 + ) # Create cables 1-3, 6-8 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) + cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) cable1.save() - cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) + cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) cable2.save() - cable3 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) + cable3 = Cable(termination_a=rearport1, termination_b=rearport2) cable3.save() - cable6 = Cable(termination_a=self.rear_port3, termination_b=self.rear_port4) + cable6 = Cable(termination_a=rearport3, termination_b=rearport4) cable6.save() - cable7 = Cable(termination_a=self.interface3, termination_b=self.front_port4_1) + cable7 = Cable(termination_a=interface3, termination_b=frontport4_1) cable7.save() - cable8 = Cable(termination_a=self.interface4, termination_b=self.front_port4_2) + cable8 = Cable(termination_a=interface4, termination_b=frontport4_2) cable8.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 4 and 5 - cable4 = Cable(termination_a=self.front_port2_1, termination_b=self.front_port3_1) + cable4 = Cable(termination_a=frontport2_1, termination_b=frontport3_1) cable4.save() - cable5 = Cable(termination_a=self.front_port2_2, termination_b=self.front_port3_2) + cable5 = Cable(termination_a=frontport2_2, termination_b=frontport3_2) cable5.save() self.assertPathExists( - origin=self.interface1, - destination=self.interface3, + origin=interface1, + destination=interface3, path=( - cable1, self.front_port1_1, self.rear_port1, cable3, self.rear_port2, self.front_port2_1, - cable4, self.front_port3_1, self.rear_port3, cable6, self.rear_port4, self.front_port4_1, + cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, + cable4, frontport3_1, rearport3, cable6, rearport4, frontport4_1, cable7 ), is_active=True ) self.assertPathExists( - origin=self.interface2, - destination=self.interface4, + origin=interface2, + destination=interface4, path=( - cable2, self.front_port1_2, self.rear_port1, cable3, self.rear_port2, self.front_port2_2, - cable5, self.front_port3_2, self.rear_port3, cable6, self.rear_port4, self.front_port4_2, + cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, + cable5, frontport3_2, rearport3, cable6, rearport4, frontport4_2, cable8 ), is_active=True ) self.assertPathExists( - origin=self.interface3, - destination=self.interface1, + origin=interface3, + destination=interface1, path=( - cable7, self.front_port4_1, self.rear_port4, cable6, self.rear_port3, self.front_port3_1, - cable4, self.front_port2_1, self.rear_port2, cable3, self.rear_port1, self.front_port1_1, + cable7, frontport4_1, rearport4, cable6, rearport3, frontport3_1, + cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1 ), is_active=True ) self.assertPathExists( - origin=self.interface4, - destination=self.interface2, + origin=interface4, + destination=interface2, path=( - cable8, self.front_port4_2, self.rear_port4, cable6, self.rear_port3, self.front_port3_2, - cable5, self.front_port2_2, self.rear_port2, cable3, self.rear_port1, self.front_port1_2, + cable8, frontport4_2, rearport4, cable6, rearport3, frontport3_2, + cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2 ), is_active=True @@ -652,63 +647,81 @@ class CablePathTestCase(TestCase): def test_205_multiple_paths_via_patched_pass_throughs(self): """ - [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP5] [RP5] --C4-- [RP2] [FP2:1] --C5-- [IF3] - [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] + [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3:1] --C5-- [IF3] + [IF2] --C2-- [FP1:2] [FP3:2] --C6-- [IF4] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.interface3.refresh_from_db() - self.interface4.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 5', rear_port=rearport2, rear_port_position=1 + ) + frontport3_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport3, rear_port_position=1 + ) + frontport3_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport3, rear_port_position=2 + ) # Create cables 1-2, 5-6 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1_1) # IF1 -> FP1:1 + cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 cable1.save() - cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_2) # IF2 -> FP1:2 + cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 cable2.save() - cable5 = Cable(termination_a=self.interface3, termination_b=self.front_port2_1) # IF3 -> FP2:1 + cable5 = Cable(termination_a=interface3, termination_b=frontport3_1) # IF3 -> FP3:1 cable5.save() - cable6 = Cable(termination_a=self.interface4, termination_b=self.front_port2_2) # IF4 -> FP2:2 + cable6 = Cable(termination_a=interface4, termination_b=frontport3_2) # IF4 -> FP3:2 cable6.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3-4 - cable3 = Cable(termination_a=self.rear_port1, termination_b=self.front_port5_1) # RP1 -> FP5 + cable3 = Cable(termination_a=rearport1, termination_b=frontport2) # RP1 -> FP2 cable3.save() - cable4 = Cable(termination_a=self.rear_port5, termination_b=self.rear_port2) # RP5 -> RP2 + cable4 = Cable(termination_a=rearport2, termination_b=rearport3) # RP2 -> RP3 cable4.save() self.assertPathExists( - origin=self.interface1, - destination=self.interface3, + origin=interface1, + destination=interface3, path=( - cable1, self.front_port1_1, self.rear_port1, cable3, self.front_port5_1, self.rear_port5, - cable4, self.rear_port2, self.front_port2_1, cable5 + cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3_1, cable5 ), is_active=True ) self.assertPathExists( - origin=self.interface2, - destination=self.interface4, + origin=interface2, + destination=interface4, path=( - cable2, self.front_port1_2, self.rear_port1, cable3, self.front_port5_1, self.rear_port5, - cable4, self.rear_port2, self.front_port2_2, cable6 + cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3_2, cable6 ), is_active=True ) self.assertPathExists( - origin=self.interface3, - destination=self.interface1, + origin=interface3, + destination=interface1, path=( - cable5, self.front_port2_1, self.rear_port2, cable4, self.rear_port5, self.front_port5_1, - cable3, self.rear_port1, self.front_port1_1, cable1 + cable5, frontport3_1, rearport3, cable4, rearport2, frontport2, + cable3, rearport1, frontport1_1, cable1 ), is_active=True ) self.assertPathExists( - origin=self.interface4, - destination=self.interface2, + origin=interface4, + destination=interface2, path=( - cable6, self.front_port2_2, self.rear_port2, cable4, self.rear_port5, self.front_port5_1, - cable3, self.rear_port1, self.front_port1_2, cable2 + cable6, frontport3_2, rearport3, cable4, rearport2, frontport2, + cable3, rearport1, frontport1_2, cable2 ), is_active=True ) @@ -726,36 +739,43 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [RP1] [FP1:1] --C2-- [IF2] [FP1:2] --C3-- [IF3] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() - self.interface3.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) # Create cables 1 - cable1 = Cable(termination_a=self.interface1, termination_b=self.rear_port1) + cable1 = Cable(termination_a=interface1, termination_b=rearport1) cable1.save() self.assertPathExists( - origin=self.interface1, + origin=interface1, destination=None, - path=(cable1, self.rear_port1), + path=(cable1, rearport1), is_active=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cables 2-3 - cable2 = Cable(termination_a=self.interface2, termination_b=self.front_port1_1) + cable2 = Cable(termination_a=interface2, termination_b=frontport1_1) cable2.save() - cable3 = Cable(termination_a=self.interface3, termination_b=self.front_port1_2) + cable3 = Cable(termination_a=interface3, termination_b=frontport1_2) cable3.save() self.assertPathExists( - origin=self.interface2, - destination=self.interface1, - path=(cable2, self.front_port1_1, self.rear_port1, cable1), + origin=interface2, + destination=interface1, + path=(cable2, frontport1_1, rearport1, cable1), is_active=True ) self.assertPathExists( - origin=self.interface3, - destination=self.interface1, - path=(cable3, self.front_port1_2, self.rear_port1, cable1), + origin=interface3, + destination=interface1, + path=(cable3, frontport1_2, rearport1, cable1), is_active=True ) self.assertEqual(CablePath.objects.count(), 3) @@ -765,76 +785,82 @@ class CablePathTestCase(TestCase): # Check that the partial path was deleted and the two complete paths are now partial self.assertPathExists( - origin=self.interface2, + origin=interface2, destination=None, - path=(cable2, self.front_port1_1, self.rear_port1), + path=(cable2, frontport1_1, rearport1), is_active=False ) self.assertPathExists( - origin=self.interface3, + origin=interface3, destination=None, - path=(cable3, self.front_port1_2, self.rear_port1), + path=(cable3, frontport1_2, rearport1), is_active=False ) self.assertEqual(CablePath.objects.count(), 2) def test_301_create_path_via_existing_cable(self): """ - [IF1] --C1-- [FP5] [RP5] --C2-- [RP6] [FP6] --C3-- [IF2] + [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) # Create cable 2 - cable2 = Cable(termination_a=self.rear_port5, termination_b=self.rear_port6) + cable2 = Cable(termination_a=rearport1, termination_b=rearport2) cable2.save() self.assertEqual(CablePath.objects.count(), 0) # Create cable1 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) + cable1 = Cable(termination_a=interface1, termination_b=frontport1) cable1.save() self.assertPathExists( - origin=self.interface1, + origin=interface1, destination=None, - path=(cable1, self.front_port5_1, self.rear_port5, cable2, self.rear_port6, self.front_port6_1), + path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2), is_active=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 3 - cable3 = Cable(termination_a=self.front_port6_1, termination_b=self.interface2) + cable3 = Cable(termination_a=frontport2, termination_b=interface2) cable3.save() self.assertPathExists( - origin=self.interface1, - destination=self.interface2, - path=( - cable1, self.front_port5_1, self.rear_port5, cable2, self.rear_port6, self.front_port6_1, - cable3, - ), + origin=interface1, + destination=interface2, + path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3), is_active=True ) self.assertPathExists( - origin=self.interface2, - destination=self.interface1, - path=( - cable3, self.front_port6_1, self.rear_port6, cable2, self.rear_port5, self.front_port5_1, - cable1, - ), + origin=interface2, + destination=interface1, + path=(cable3, frontport2, rearport2, cable2, rearport1, frontport1, cable1), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) def test_302_update_path_on_cable_status_change(self): """ - [IF1] --C1-- [FP5] [RP5] --C2-- [IF2] + [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] """ - self.interface1.refresh_from_db() - self.interface2.refresh_from_db() + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) # Create cables 1 and 2 - cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port5_1) + cable1 = Cable(termination_a=interface1, termination_b=frontport1) cable1.save() - cable2 = Cable(termination_a=self.rear_port5, termination_b=self.interface2) + cable2 = Cable(termination_a=rearport1, termination_b=interface2) cable2.save() self.assertEqual(CablePath.objects.filter(is_active=True).count(), 2) self.assertEqual(CablePath.objects.count(), 2) @@ -843,15 +869,15 @@ class CablePathTestCase(TestCase): cable2.status = CableStatusChoices.STATUS_PLANNED cable2.save() self.assertPathExists( - origin=self.interface1, - destination=self.interface2, - path=(cable1, self.front_port5_1, self.rear_port5, cable2), + origin=interface1, + destination=interface2, + path=(cable1, frontport1, rearport1, cable2), is_active=False ) self.assertPathExists( - origin=self.interface2, - destination=self.interface1, - path=(cable2, self.rear_port5, self.front_port5_1, cable1), + origin=interface2, + destination=interface1, + path=(cable2, rearport1, frontport1, cable1), is_active=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -861,15 +887,15 @@ class CablePathTestCase(TestCase): cable2.status = CableStatusChoices.STATUS_CONNECTED cable2.save() self.assertPathExists( - origin=self.interface1, - destination=self.interface2, - path=(cable1, self.front_port5_1, self.rear_port5, cable2), + origin=interface1, + destination=interface2, + path=(cable1, frontport1, rearport1, cable2), is_active=True ) self.assertPathExists( - origin=self.interface2, - destination=self.interface1, - path=(cable2, self.rear_port5, self.front_port5_1, cable1), + origin=interface2, + destination=interface1, + path=(cable2, rearport1, frontport1, cable1), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) From 44caa402d0342a0e631690d610b749468dadb5d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 15:01:55 -0400 Subject: [PATCH 131/291] Delete obsolete LoopDetected exception --- netbox/dcim/exceptions.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 netbox/dcim/exceptions.py diff --git a/netbox/dcim/exceptions.py b/netbox/dcim/exceptions.py deleted file mode 100644 index e788c9b5f..000000000 --- a/netbox/dcim/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -class LoopDetected(Exception): - """ - A loop has been detected while tracing a cable path. - """ - pass From 752b099d22aa62d8d6f5f8a63a8c9f35d99f4c30 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Oct 2020 15:30:59 -0400 Subject: [PATCH 132/291] Exempt InventoryItem from queryset caching (MPTT) --- netbox/netbox/settings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index db34caefb..353c9d356 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -422,14 +422,15 @@ CACHEOPS = { 'auth.*': {'ops': ('fetch', 'get')}, 'auth.permission': {'ops': 'all'}, 'circuits.*': {'ops': 'all'}, - 'dcim.region': None, # MPTT models are exempt due to raw sql - 'dcim.rackgroup': None, # MPTT models are exempt due to raw sql + 'dcim.inventoryitem': None, # MPTT models are exempt due to raw SQL + 'dcim.region': None, # MPTT models are exempt due to raw SQL + 'dcim.rackgroup': None, # MPTT models are exempt due to raw SQL 'dcim.*': {'ops': 'all'}, 'ipam.*': {'ops': 'all'}, 'extras.*': {'ops': 'all'}, 'secrets.*': {'ops': 'all'}, 'users.*': {'ops': 'all'}, - 'tenancy.tenantgroup': None, # MPTT models are exempt due to raw sql + 'tenancy.tenantgroup': None, # MPTT models are exempt due to raw SQL 'tenancy.*': {'ops': 'all'}, 'virtualization.*': {'ops': 'all'}, } From 30778a9c40db4996e5a90108c6304489d916e935 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Oct 2020 14:06:24 -0400 Subject: [PATCH 133/291] Closes #5225: CircuitTermination port_speed is now optional --- docs/release-notes/version-2.10.md | 2 ++ ...3_circuittermination_port_speed_optional.py | 18 ++++++++++++++++++ netbox/circuits/models.py | 4 +++- netbox/circuits/tests/test_api.py | 8 ++++---- netbox/circuits/tests/test_filters.py | 10 +++++----- netbox/dcim/tests/test_cablepaths.py | 4 +--- netbox/dcim/tests/test_models.py | 4 ++-- .../circuits/circuittermination_edit.html | 2 +- .../circuits/inc/circuit_termination.html | 6 ++++-- 9 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 netbox/circuits/migrations/0023_circuittermination_port_speed_optional.py diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 0e636fc45..26b13477a 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -57,6 +57,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates * [#4878](https://github.com/netbox-community/netbox/issues/4878) - Custom field data is now stored directly on each object * [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method +* [#5225](https://github.com/netbox-community/netbox/issues/5225) - Circuit termination port speed is now an optional field ### REST API Changes @@ -65,6 +66,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * Added the `/trace/` endpoint * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) * Added `cable_peer` and `cable_peer_type` + * `port_speed` may now be null * dcim.Cable: Added `custom_fields` * dcim.ConsolePort: * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) diff --git a/netbox/circuits/migrations/0023_circuittermination_port_speed_optional.py b/netbox/circuits/migrations/0023_circuittermination_port_speed_optional.py new file mode 100644 index 000000000..ea9190623 --- /dev/null +++ b/netbox/circuits/migrations/0023_circuittermination_port_speed_optional.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-10-09 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0022_cablepath'), + ] + + operations = [ + migrations.AlterField( + model_name='circuittermination', + name='port_speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 725fe4b3f..a4eed83f0 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -248,7 +248,9 @@ class CircuitTermination(PathEndpoint, CableTermination): related_name='circuit_terminations' ) port_speed = models.PositiveIntegerField( - verbose_name='Port speed (Kbps)' + verbose_name='Port speed (Kbps)', + blank=True, + null=True ) upstream_speed = models.PositiveIntegerField( blank=True, diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 48493d5ef..26ec5928b 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -153,10 +153,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): Circuit.objects.bulk_create(circuits) circuit_terminations = ( - CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A), - CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z), - CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A), - CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z), + CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A), + CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z), + CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A), + CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z), ) CircuitTermination.objects.bulk_create(circuit_terminations) diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 73701be03..9477bfbac 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -50,8 +50,8 @@ class ProviderTestCase(TestCase): Circuit.objects.bulk_create(circuits) CircuitTermination.objects.bulk_create(( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000), - CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000), + CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'), )) def test_id(self): @@ -176,9 +176,9 @@ class CircuitTestCase(TestCase): Circuit.objects.bulk_create(circuits) circuit_terminations = (( - CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000), - CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=1000), - CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=1000), + CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), + CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 5699b3b88..9e0f0e77f 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -234,9 +234,7 @@ class CablePathTestCase(TestCase): [IF1] --C1-- [CT1A] """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') - circuittermination1 = CircuitTermination.objects.create( - circuit=self.circuit, site=self.site, term_side='A', port_speed=1000 - ) + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') # Create cable 1 cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 01829d7bc..85fa44be5 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -390,8 +390,8 @@ class CableTestCase(TestCase): self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000) - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000) + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A') + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z') def test_cable_creation(self): """ diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 8a6171e5f..ad41e7d60 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -47,7 +47,7 @@
    Termination Details
    - +
    {{ form.port_speed }} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index abbb79a14..511ab1977 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -83,11 +83,13 @@ Speed - {% if termination.upstream_speed %} + {% if termination.port_speed and termination.upstream_speed %} {{ termination.port_speed|humanize_speed }}   {{ termination.upstream_speed|humanize_speed }} - {% else %} + {% elif termination.port_speed %} {{ termination.port_speed|humanize_speed }} + {% else %} + {% endif %} From 66c4597525e69f5c4ebc22039a9f6b51422ab946 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Oct 2020 14:38:21 -0400 Subject: [PATCH 134/291] Add RouteTarget to __all__ --- netbox/ipam/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 89ea672c7..11ce180a4 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -30,6 +30,7 @@ __all__ = ( 'Prefix', 'RIR', 'Role', + 'RouteTarget', 'Service', 'VLAN', 'VLANGroup', From 3df3706f277c7c046d844d8079d21b261dbdbf80 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Oct 2020 15:08:29 -0400 Subject: [PATCH 135/291] Closes #5190: Add a REST API endpoint for content types --- docs/release-notes/version-2.10.md | 2 ++ netbox/extras/api/serializers.py | 17 +++++++++++++++++ netbox/extras/api/urls.py | 3 +++ netbox/extras/api/views.py | 17 +++++++++++++++-- netbox/extras/filters.py | 12 ++++++++++++ netbox/extras/forms.py | 11 +++++++---- netbox/extras/tests/test_api.py | 19 +++++++++++++++++++ 7 files changed, 75 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 26b13477a..69df8c15d 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -49,6 +49,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis +* [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for content types ### Other Changes @@ -62,6 +63,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al ### REST API Changes * Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete) +* Added `/extras/content-types/` endpoint for Django ContentTypes * circuits.CircuitTermination: * Added the `/trace/` endpoint * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c06d4b32d..0071791fa 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -339,3 +339,20 @@ class ObjectChangeSerializer(serializers.ModelSerializer): data = serializer(obj.changed_object, context=context).data return data + + +# +# ContentTypes +# + +class ContentTypeSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail') + display_name = serializers.SerializerMethodField() + + class Meta: + model = ContentType + fields = ['id', 'url', 'app_label', 'model', 'display_name'] + + @swagger_serializer_method(serializer_or_field=serializers.CharField) + def get_display_name(self, obj): + return obj.app_labeled_name diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 20d0f8d17..d5d7e15b6 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -29,5 +29,8 @@ router.register('object-changes', views.ObjectChangeViewSet) # Job Results router.register('job-results', views.JobResultViewSet) +# ContentTypes +router.register('content-types', views.ContentTypeViewSet) + app_name = 'extras-api' urlpatterns = router.urls diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 940cc7912..96dcb4d8f 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.http import Http404 @@ -311,3 +309,18 @@ class JobResultViewSet(ReadOnlyModelViewSet): queryset = JobResult.objects.prefetch_related('user') serializer_class = serializers.JobResultSerializer filterset_class = filters.JobResultFilterSet + + +# +# ContentTypes +# + +class ContentTypeViewSet(ReadOnlyModelViewSet): + """ + Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects. + """ + queryset = ContentType.objects.order_by('app_label', 'model').filter(app_label__in=( + 'circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization' + )) + serializer_class = serializers.ContentTypeSerializer + filterset_class = filters.ContentTypeFilterSet diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index ad5884b7a..91f4d333b 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -13,6 +13,7 @@ from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, __all__ = ( 'ConfigContextFilterSet', + 'ContentTypeFilterSet', 'CreatedUpdatedFilterSet', 'CustomFieldFilter', 'CustomFieldFilterSet', @@ -313,3 +314,14 @@ class JobResultFilterSet(BaseFilterSet): return queryset.filter( Q(user__username__icontains=value) ) + + +# +# ContentTypes +# + +class ContentTypeFilterSet(django_filters.FilterSet): + + class Meta: + model = ContentType + fields = ['id', 'app_label', 'model'] diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 7e7587901..d09ff64fe 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -362,11 +362,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): api_url='/api/users/users/', ) ) - changed_object_type_id = forms.ModelChoiceField( - queryset=ContentType.objects.order_by('app_label', 'model'), + changed_object_type_id = DynamicModelMultipleChoiceField( + queryset=ContentType.objects.all(), required=False, - widget=ContentTypeSelect(), - label='Object Type' + display_field='display_name', + label='Object Type', + widget=APISelectMultiple( + api_url='/api/extras/content-types/', + ) ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 860aed56f..66acb0741 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -2,6 +2,7 @@ import datetime from unittest import skipIf from django.contrib.contenttypes.models import ContentType +from django.test import override_settings from django.urls import reverse from django.utils import timezone from django_rq.queues import get_connection @@ -396,3 +397,21 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['count'], 1) self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) + + +class ContentTypeTest(APITestCase): + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['contenttypes.contenttype']) + def test_list_objects(self): + contenttype_count = ContentType.objects.count() + + response = self.client.get(reverse('extras-api:contenttype-list'), **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], contenttype_count) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['contenttypes.contenttype']) + def test_get_object(self): + contenttype = ContentType.objects.first() + + url = reverse('extras-api:contenttype-detail', kwargs={'pk': contenttype.pk}) + self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) From d61d62088fb885d6c93197fcb6813b8ccc7f5dbd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Oct 2020 15:11:56 -0400 Subject: [PATCH 136/291] Ditch hard-coded filtering of ContentTypes API endpoint --- netbox/extras/api/views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 96dcb4d8f..75a501862 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -319,8 +319,6 @@ class ContentTypeViewSet(ReadOnlyModelViewSet): """ Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects. """ - queryset = ContentType.objects.order_by('app_label', 'model').filter(app_label__in=( - 'circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization' - )) + queryset = ContentType.objects.order_by('app_label', 'model') serializer_class = serializers.ContentTypeSerializer filterset_class = filters.ContentTypeFilterSet From c0c5f52ed9df38188fc7cbe2d0890756730cd31c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 13 Oct 2020 15:54:23 -0400 Subject: [PATCH 137/291] Reorganize REST API components under netbox app --- netbox/circuits/api/nested_serializers.py | 2 +- netbox/circuits/api/serializers.py | 2 +- netbox/circuits/api/urls.py | 2 +- netbox/circuits/api/views.py | 2 +- netbox/dcim/api/nested_serializers.py | 2 +- netbox/dcim/api/serializers.py | 9 +- netbox/dcim/api/urls.py | 2 +- netbox/dcim/api/views.py | 9 +- netbox/extras/api/customfields.py | 2 +- netbox/extras/api/nested_serializers.py | 2 +- netbox/extras/api/serializers.py | 7 +- netbox/extras/api/urls.py | 2 +- netbox/extras/api/views.py | 5 +- netbox/ipam/api/nested_serializers.py | 2 +- netbox/ipam/api/serializers.py | 5 +- netbox/ipam/api/urls.py | 2 +- netbox/ipam/api/views.py | 2 +- netbox/netbox/api.py | 207 ------- netbox/netbox/api/__init__.py | 30 + netbox/netbox/api/authentication.py | 84 +++ netbox/netbox/api/exceptions.py | 10 + netbox/netbox/api/fields.py | 133 +++++ netbox/{utilities => netbox/api}/metadata.py | 5 +- netbox/netbox/api/pagination.py | 69 +++ netbox/netbox/api/renderers.py | 12 + netbox/netbox/api/routers.py | 27 + netbox/netbox/api/serializers.py | 91 ++++ netbox/netbox/api/views.py | 220 ++++++++ netbox/netbox/settings.py | 10 +- netbox/secrets/api/nested_serializers.py | 2 +- netbox/secrets/api/serializers.py | 3 +- netbox/secrets/api/urls.py | 2 +- netbox/secrets/api/views.py | 2 +- netbox/tenancy/api/nested_serializers.py | 2 +- netbox/tenancy/api/serializers.py | 2 +- netbox/tenancy/api/urls.py | 2 +- netbox/tenancy/api/views.py | 2 +- netbox/users/api/nested_serializers.py | 2 +- netbox/users/api/serializers.py | 2 +- netbox/users/api/urls.py | 2 +- netbox/users/api/views.py | 2 +- netbox/utilities/api.py | 511 +----------------- netbox/utilities/custom_inspectors.py | 2 +- .../virtualization/api/nested_serializers.py | 2 +- netbox/virtualization/api/serializers.py | 2 +- netbox/virtualization/api/urls.py | 2 +- netbox/virtualization/api/views.py | 2 +- 47 files changed, 753 insertions(+), 750 deletions(-) delete mode 100644 netbox/netbox/api.py create mode 100644 netbox/netbox/api/__init__.py create mode 100644 netbox/netbox/api/authentication.py create mode 100644 netbox/netbox/api/exceptions.py create mode 100644 netbox/netbox/api/fields.py rename netbox/{utilities => netbox/api}/metadata.py (94%) create mode 100644 netbox/netbox/api/pagination.py create mode 100644 netbox/netbox/api/renderers.py create mode 100644 netbox/netbox/api/routers.py create mode 100644 netbox/netbox/api/serializers.py create mode 100644 netbox/netbox/api/views.py diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 076883282..2d3457d2c 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ad5e609e4..88890bf95 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -6,8 +6,8 @@ from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSe from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from .nested_serializers import * diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 0bcb2d280..b496796fe 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 516831983..f64dbc2dd 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -5,7 +5,7 @@ from circuits import filters from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from dcim.api.views import PathEndpointMixin from extras.api.views import CustomFieldModelViewSet -from utilities.api import ModelViewSet +from netbox.api.views import ModelViewSet from . import serializers diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 159540ece..d63d32d68 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim import models -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCableSerializer', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d6da5a5e3..6008188bb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -18,12 +18,13 @@ from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN +from netbox.api import ( + ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, +) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, - WritableNestedSerializer, get_serializer_for_model, -) +from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer from .nested_serializers import * diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index e8c4fbe1d..689cb7aa1 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b14c67e65..08105460f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -25,11 +25,12 @@ from dcim.models import ( ) from extras.api.views import CustomFieldModelViewSet from ipam.models import Prefix, VLAN -from utilities.api import ( - get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, -) +from netbox.api.views import ModelViewSet +from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.api.exceptions import ServiceUnavailable +from netbox.api.metadata import ContentTypeMetadata +from utilities.api import get_serializer_for_model from utilities.utils import get_subquery -from utilities.metadata import ContentTypeMetadata from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 52c9a18ab..a5f11fde6 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -6,7 +6,7 @@ from rest_framework.fields import CreateOnlyDefault, Field from extras.choices import * from extras.models import CustomField -from utilities.api import ValidatedModelSerializer +from netbox.api import ValidatedModelSerializer # diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 95c980768..762bfb0d9 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers from extras import choices, models +from netbox.api import ChoiceField, WritableNestedSerializer from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ChoiceField, WritableNestedSerializer __all__ = [ 'NestedConfigContextSerializer', diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0071791fa..268a5d7c0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,13 +13,12 @@ from extras.models import ( ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, ) from extras.utils import FeatureQuery +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api.exceptions import SerializerNotFound from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ( - ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField, - ValidatedModelSerializer, -) +from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index d5d7e15b6..917aedca5 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 75a501862..dc0c6ad7c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -15,9 +15,10 @@ from extras.choices import JobResultStatusChoices from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script -from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet +from netbox.api.views import ModelViewSet +from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired +from netbox.api.metadata import ContentTypeMetadata from utilities.exceptions import RQWorkerNotRunningException -from utilities.metadata import ContentTypeMetadata from utilities.utils import copy_safe_request from . import serializers diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 004ac070c..660db2b22 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from ipam import models -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ed8680a72..31f708c86 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -11,10 +11,9 @@ from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, get_serializer_for_model, -) +from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index a8cbf7a29..13a1bc770 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 449ef3245..0d50b6ea7 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -11,7 +11,7 @@ from rest_framework.routers import APIRootView from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF -from utilities.api import ModelViewSet +from netbox.api.views import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery from . import serializers diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py deleted file mode 100644 index 4b60084c6..000000000 --- a/netbox/netbox/api.py +++ /dev/null @@ -1,207 +0,0 @@ -from django.conf import settings -from django.db.models import QuerySet -from rest_framework import authentication, exceptions -from rest_framework.pagination import LimitOffsetPagination -from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS -from rest_framework.renderers import BrowsableAPIRenderer -from rest_framework.schemas import coreapi -from rest_framework.utils import formatting - -from users.models import Token - - -def is_custom_action(action): - return action not in { - # Default actions - 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', - # Bulk operations - 'bulk_update', 'bulk_partial_update', 'bulk_destroy', - } - - -# Monkey-patch DRF to treat bulk_destroy() as a non-custom action (see #3436) -coreapi.is_custom_action = is_custom_action - - -# -# Renderers -# - -class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): - """ - Override the built-in BrowsableAPIRenderer to disable HTML forms. - """ - def show_form_for_method(self, *args, **kwargs): - return False - - def get_filter_form(self, data, view, request): - return None - - -# -# Authentication -# - -class TokenAuthentication(authentication.TokenAuthentication): - """ - A custom authentication scheme which enforces Token expiration times. - """ - model = Token - - def authenticate_credentials(self, key): - model = self.get_model() - try: - token = model.objects.prefetch_related('user').get(key=key) - except model.DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid token") - - # Enforce the Token's expiration time, if one has been set. - if token.is_expired: - raise exceptions.AuthenticationFailed("Token expired") - - if not token.user.is_active: - raise exceptions.AuthenticationFailed("User inactive") - - return token.user, token - - -class TokenPermissions(DjangoObjectPermissions): - """ - Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability - for unsafe requests (POST/PUT/PATCH/DELETE). - """ - # Override the stock perm_map to enforce view permissions - perms_map = { - 'GET': ['%(app_label)s.view_%(model_name)s'], - 'OPTIONS': [], - 'HEAD': ['%(app_label)s.view_%(model_name)s'], - 'POST': ['%(app_label)s.add_%(model_name)s'], - 'PUT': ['%(app_label)s.change_%(model_name)s'], - 'PATCH': ['%(app_label)s.change_%(model_name)s'], - 'DELETE': ['%(app_label)s.delete_%(model_name)s'], - } - - def __init__(self): - - # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. - self.authenticated_users_only = settings.LOGIN_REQUIRED - - super().__init__() - - def _verify_write_permission(self, request): - - # If token authentication is in use, verify that the token allows write operations (for unsafe methods). - if request.method in SAFE_METHODS or request.auth.write_enabled: - return True - - def has_permission(self, request, view): - - # Enforce Token write ability - if isinstance(request.auth, Token) and not self._verify_write_permission(request): - return False - - return super().has_permission(request, view) - - def has_object_permission(self, request, view, obj): - - # Enforce Token write ability - if isinstance(request.auth, Token) and not self._verify_write_permission(request): - return False - - return super().has_object_permission(request, view, obj) - - -# -# Pagination -# - -class OptionalLimitOffsetPagination(LimitOffsetPagination): - """ - Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects - matching a query, but retains the same format as a paginated request. The limit can only be disabled if - MAX_PAGE_SIZE has been set to 0 or None. - """ - - def paginate_queryset(self, queryset, request, view=None): - - if isinstance(queryset, QuerySet): - self.count = queryset.count() - else: - # We're dealing with an iterable, not a QuerySet - self.count = len(queryset) - - self.limit = self.get_limit(request) - self.offset = self.get_offset(request) - self.request = request - - if self.limit and self.count > self.limit and self.template is not None: - self.display_page_controls = True - - if self.count == 0 or self.offset > self.count: - return list() - - if self.limit: - return list(queryset[self.offset:self.offset + self.limit]) - else: - return list(queryset[self.offset:]) - - def get_limit(self, request): - - if self.limit_query_param: - try: - limit = int(request.query_params[self.limit_query_param]) - if limit < 0: - raise ValueError() - # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) - return limit - except (KeyError, ValueError): - pass - - return self.default_limit - - def get_next_link(self): - - # Pagination has been disabled - if not self.limit: - return None - - return super().get_next_link() - - def get_previous_link(self): - - # Pagination has been disabled - if not self.limit: - return None - - return super().get_previous_link() - - -# -# Miscellaneous -# - -def get_view_name(view, suffix=None): - """ - Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. - """ - if hasattr(view, 'queryset'): - # Determine the model name from the queryset. - name = view.queryset.model._meta.verbose_name - name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word - - else: - # Replicate DRF's built-in behavior. - name = view.__class__.__name__ - name = formatting.remove_trailing_string(name, 'View') - name = formatting.remove_trailing_string(name, 'ViewSet') - name = formatting.camelcase_to_spaces(name) - - if suffix: - name += ' ' + suffix - - return name diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py new file mode 100644 index 000000000..afb2a6803 --- /dev/null +++ b/netbox/netbox/api/__init__.py @@ -0,0 +1,30 @@ +from rest_framework.schemas import coreapi + +from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField +from .routers import OrderedDefaultRouter +from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer + + +__all__ = ( + 'BulkOperationSerializer', + 'ChoiceField', + 'ContentTypeField', + 'OrderedDefaultRouter', + 'SerializedPKRelatedField', + 'TimeZoneField', + 'ValidatedModelSerializer', + 'WritableNestedSerializer', +) + + +def is_custom_action(action): + return action not in { + # Default actions + 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', + # Bulk operations + 'bulk_update', 'bulk_partial_update', 'bulk_destroy', + } + + +# Monkey-patch DRF to treat bulk_destroy() as a non-custom action (see #3436) +coreapi.is_custom_action = is_custom_action diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py new file mode 100644 index 000000000..1cb32c1e4 --- /dev/null +++ b/netbox/netbox/api/authentication.py @@ -0,0 +1,84 @@ +from django.conf import settings +from rest_framework import authentication, exceptions +from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS + +from users.models import Token + + +class TokenAuthentication(authentication.TokenAuthentication): + """ + A custom authentication scheme which enforces Token expiration times. + """ + model = Token + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.prefetch_related('user').get(key=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid token") + + # Enforce the Token's expiration time, if one has been set. + if token.is_expired: + raise exceptions.AuthenticationFailed("Token expired") + + if not token.user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return token.user, token + + +class TokenPermissions(DjangoObjectPermissions): + """ + Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability + for unsafe requests (POST/PUT/PATCH/DELETE). + """ + # Override the stock perm_map to enforce view permissions + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': [], + 'HEAD': ['%(app_label)s.view_%(model_name)s'], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + def __init__(self): + + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. + self.authenticated_users_only = settings.LOGIN_REQUIRED + + super().__init__() + + def _verify_write_permission(self, request): + + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method in SAFE_METHODS or request.auth.write_enabled: + return True + + def has_permission(self, request, view): + + # Enforce Token write ability + if isinstance(request.auth, Token) and not self._verify_write_permission(request): + return False + + return super().has_permission(request, view) + + def has_object_permission(self, request, view, obj): + + # Enforce Token write ability + if isinstance(request.auth, Token) and not self._verify_write_permission(request): + return False + + return super().has_object_permission(request, view, obj) + + +class IsAuthenticatedOrLoginNotRequired(BasePermission): + """ + Returns True if the user is authenticated or LOGIN_REQUIRED is False. + """ + def has_permission(self, request, view): + if not settings.LOGIN_REQUIRED: + return True + return request.user.is_authenticated diff --git a/netbox/netbox/api/exceptions.py b/netbox/netbox/api/exceptions.py new file mode 100644 index 000000000..8c62eee4c --- /dev/null +++ b/netbox/netbox/api/exceptions.py @@ -0,0 +1,10 @@ +from rest_framework.exceptions import APIException + + +class ServiceUnavailable(APIException): + status_code = 503 + default_detail = "Service temporarily unavailable, please try again later." + + +class SerializerNotFound(Exception): + pass diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py new file mode 100644 index 000000000..ca66d97d4 --- /dev/null +++ b/netbox/netbox/api/fields.py @@ -0,0 +1,133 @@ +from collections import OrderedDict + +import pytz +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework.relations import PrimaryKeyRelatedField, RelatedField + + +class ChoiceField(serializers.Field): + """ + Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. + + :param choices: An iterable of choices in the form (value, key). + :param allow_blank: Allow blank values in addition to the listed choices. + """ + def __init__(self, choices, allow_blank=False, **kwargs): + self.choiceset = choices + self.allow_blank = allow_blank + self._choices = dict() + + # Unpack grouped choices + for k, v in choices: + if type(v) in [list, tuple]: + for k2, v2 in v: + self._choices[k2] = v2 + else: + self._choices[k] = v + + super().__init__(**kwargs) + + def validate_empty_values(self, data): + # Convert null to an empty string unless allow_null == True + if data is None: + if self.allow_null: + return True, None + else: + data = '' + return super().validate_empty_values(data) + + def to_representation(self, obj): + if obj is '': + return None + return OrderedDict([ + ('value', obj), + ('label', self._choices[obj]) + ]) + + def to_internal_value(self, data): + if data is '': + if self.allow_blank: + return data + raise ValidationError("This field may not be blank.") + + # Provide an explicit error message if the request is trying to write a dict or list + if isinstance(data, (dict, list)): + raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.') + + # Check for string representations of boolean/integer values + if hasattr(data, 'lower'): + if data.lower() == 'true': + data = True + elif data.lower() == 'false': + data = False + else: + try: + data = int(data) + except ValueError: + pass + + try: + if data in self._choices: + return data + except TypeError: # Input is an unhashable type + pass + + raise ValidationError(f"{data} is not a valid choice.") + + @property + def choices(self): + return self._choices + + +class ContentTypeField(RelatedField): + """ + Represent a ContentType as '.' + """ + default_error_messages = { + "does_not_exist": "Invalid content type: {content_type}", + "invalid": "Invalid value. Specify a content type as '.'.", + } + + def to_internal_value(self, data): + try: + app_label, model = data.split('.') + return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) + except ObjectDoesNotExist: + self.fail('does_not_exist', content_type=data) + except (TypeError, ValueError): + self.fail('invalid') + + def to_representation(self, obj): + return "{}.{}".format(obj.app_label, obj.model) + + +class TimeZoneField(serializers.Field): + """ + Represent a pytz time zone. + """ + def to_representation(self, obj): + return obj.zone if obj else None + + def to_internal_value(self, data): + if not data: + return "" + if data not in pytz.common_timezones: + raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data)) + return pytz.timezone(data) + + +class SerializedPKRelatedField(PrimaryKeyRelatedField): + """ + Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related + objects in a ManyToManyField while still allowing a set of primary keys to be written. + """ + def __init__(self, serializer, **kwargs): + self.serializer = serializer + self.pk_field = kwargs.pop('pk_field', None) + super().__init__(**kwargs) + + def to_representation(self, value): + return self.serializer(value, context={'request': self.context['request']}).data diff --git a/netbox/utilities/metadata.py b/netbox/netbox/api/metadata.py similarity index 94% rename from netbox/utilities/metadata.py rename to netbox/netbox/api/metadata.py index 8fd664d5a..1d0397e4d 100644 --- a/netbox/utilities/metadata.py +++ b/netbox/netbox/api/metadata.py @@ -1,6 +1,7 @@ -from rest_framework.metadata import SimpleMetadata from django.utils.encoding import force_str -from utilities.api import ContentTypeField +from rest_framework.metadata import SimpleMetadata + +from netbox.api import ContentTypeField class ContentTypeMetadata(SimpleMetadata): diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py new file mode 100644 index 000000000..d489ce951 --- /dev/null +++ b/netbox/netbox/api/pagination.py @@ -0,0 +1,69 @@ +from django.conf import settings +from django.db.models import QuerySet +from rest_framework.pagination import LimitOffsetPagination + + +class OptionalLimitOffsetPagination(LimitOffsetPagination): + """ + Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects + matching a query, but retains the same format as a paginated request. The limit can only be disabled if + MAX_PAGE_SIZE has been set to 0 or None. + """ + + def paginate_queryset(self, queryset, request, view=None): + + if isinstance(queryset, QuerySet): + self.count = queryset.count() + else: + # We're dealing with an iterable, not a QuerySet + self.count = len(queryset) + + self.limit = self.get_limit(request) + self.offset = self.get_offset(request) + self.request = request + + if self.limit and self.count > self.limit and self.template is not None: + self.display_page_controls = True + + if self.count == 0 or self.offset > self.count: + return list() + + if self.limit: + return list(queryset[self.offset:self.offset + self.limit]) + else: + return list(queryset[self.offset:]) + + def get_limit(self, request): + + if self.limit_query_param: + try: + limit = int(request.query_params[self.limit_query_param]) + if limit < 0: + raise ValueError() + # Enforce maximum page size, if defined + if settings.MAX_PAGE_SIZE: + if limit == 0: + return settings.MAX_PAGE_SIZE + else: + return min(limit, settings.MAX_PAGE_SIZE) + return limit + except (KeyError, ValueError): + pass + + return self.default_limit + + def get_next_link(self): + + # Pagination has been disabled + if not self.limit: + return None + + return super().get_next_link() + + def get_previous_link(self): + + # Pagination has been disabled + if not self.limit: + return None + + return super().get_previous_link() diff --git a/netbox/netbox/api/renderers.py b/netbox/netbox/api/renderers.py new file mode 100644 index 000000000..c492510fb --- /dev/null +++ b/netbox/netbox/api/renderers.py @@ -0,0 +1,12 @@ +from rest_framework.renderers import BrowsableAPIRenderer + + +class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): + """ + Override the built-in BrowsableAPIRenderer to disable HTML forms. + """ + def show_form_for_method(self, *args, **kwargs): + return False + + def get_filter_form(self, data, view, request): + return None diff --git a/netbox/netbox/api/routers.py b/netbox/netbox/api/routers.py new file mode 100644 index 000000000..71df1796e --- /dev/null +++ b/netbox/netbox/api/routers.py @@ -0,0 +1,27 @@ +from collections import OrderedDict + +from rest_framework.routers import DefaultRouter + + +class OrderedDefaultRouter(DefaultRouter): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Extend the list view mappings to support the DELETE operation + self.routes[0].mapping.update({ + 'put': 'bulk_update', + 'patch': 'bulk_partial_update', + 'delete': 'bulk_destroy', + }) + + def get_api_root_view(self, api_urls=None): + """ + Wrap DRF's DefaultRouter to return an alphabetized list of endpoints. + """ + api_root_dict = OrderedDict() + list_name = self.routes[0].name + for prefix, viewset, basename in sorted(self.registry, key=lambda x: x[0]): + api_root_dict[prefix] = list_name.format(basename=basename) + + return self.APIRootView.as_view(api_root_dict=api_root_dict) diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py new file mode 100644 index 000000000..c5ecf9372 --- /dev/null +++ b/netbox/netbox/api/serializers.py @@ -0,0 +1,91 @@ +from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import ManyToManyField +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from utilities.utils import dict_to_filter_params + + +# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant +# way to enforce model validation on the serializer. +class ValidatedModelSerializer(serializers.ModelSerializer): + """ + Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. + """ + def validate(self, data): + + # Remove custom fields data and tags (if any) prior to model validation + attrs = data.copy() + attrs.pop('custom_fields', None) + attrs.pop('tags', None) + + # Skip ManyToManyFields + for field in self.Meta.model._meta.get_fields(): + if isinstance(field, ManyToManyField): + attrs.pop(field.name, None) + + # Run clean() on an instance of the model + if self.instance is None: + instance = self.Meta.model(**attrs) + else: + instance = self.instance + for k, v in attrs.items(): + setattr(instance, k, v) + instance.clean() + instance.validate_unique() + + return data + + +class WritableNestedSerializer(serializers.ModelSerializer): + """ + Returns a nested representation of an object on read, but accepts only a primary key on write. + """ + + def to_internal_value(self, data): + + if data is None: + return None + + # Dictionary of related object attributes + if isinstance(data, dict): + params = dict_to_filter_params(data) + queryset = self.Meta.model.objects + try: + return queryset.get(**params) + except ObjectDoesNotExist: + raise ValidationError( + "Related object not found using the provided attributes: {}".format(params) + ) + except MultipleObjectsReturned: + raise ValidationError( + "Multiple objects match the provided attributes: {}".format(params) + ) + except FieldError as e: + raise ValidationError(e) + + # Integer PK of related object + if isinstance(data, int): + pk = data + else: + try: + # PK might have been mistakenly passed as a string + pk = int(data) + except (TypeError, ValueError): + raise ValidationError( + "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " + "unrecognized value: {}".format(data) + ) + + # Look up object by PK + queryset = self.Meta.model.objects + try: + return queryset.get(pk=int(data)) + except ObjectDoesNotExist: + raise ValidationError( + "Related object not found using the provided numeric ID: {}".format(pk) + ) + + +class BulkOperationSerializer(serializers.Serializer): + id = serializers.IntegerField() diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py new file mode 100644 index 000000000..3dd512205 --- /dev/null +++ b/netbox/netbox/api/views.py @@ -0,0 +1,220 @@ +import logging + +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import transaction +from django.db.models import ProtectedError +from rest_framework import mixins, status +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from netbox.api import BulkOperationSerializer +from netbox.api.exceptions import SerializerNotFound +from utilities.api import get_serializer_for_model + +HTTP_ACTIONS = { + 'GET': 'view', + 'OPTIONS': None, + 'HEAD': 'view', + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', +} + + +# +# Mixins +# + +class BulkUpdateModelMixin: + """ + Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one + or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set. + For example: + + PATCH /api/dcim/sites/ + [ + { + "id": 123, + "name": "New name" + }, + { + "id": 456, + "status": "planned" + } + ] + """ + def bulk_update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + serializer = BulkOperationSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) + + # Map update data by object ID + update_data = { + obj.pop('id'): obj for obj in request.data + } + + self.perform_bulk_update(qs, update_data, partial=partial) + + return Response(status=status.HTTP_200_OK) + + def perform_bulk_update(self, objects, update_data, partial): + with transaction.atomic(): + for obj in objects: + data = update_data.get(obj.id) + serializer = self.get_serializer(obj, data=data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + def bulk_partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.bulk_update(request, *args, **kwargs) + + +class BulkDestroyModelMixin: + """ + Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one + or more JSON objects, each specifying the numeric ID of an object to be deleted. For example: + + DELETE /api/dcim/sites/ + [ + {"id": 123}, + {"id": 456} + ] + """ + def bulk_destroy(self, request, *args, **kwargs): + serializer = BulkOperationSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) + + self.perform_bulk_destroy(qs) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def perform_bulk_destroy(self, objects): + with transaction.atomic(): + for obj in objects: + self.perform_destroy(obj) + + +# +# Viewsets +# + +class ModelViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + BulkUpdateModelMixin, + BulkDestroyModelMixin, + GenericViewSet): + """ + Accept either a single object or a list of objects to create. + """ + def get_serializer(self, *args, **kwargs): + + # If a list of objects has been provided, initialize the serializer with many=True + if isinstance(kwargs.get('data', {}), list): + kwargs['many'] = True + + return super().get_serializer(*args, **kwargs) + + def get_serializer_class(self): + logger = logging.getLogger('netbox.api.views.ModelViewSet') + + # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one + # exists + request = self.get_serializer_context()['request'] + if request.query_params.get('brief'): + logger.debug("Request is for 'brief' format; initializing nested serializer") + try: + serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') + logger.debug(f"Using serializer {serializer}") + return serializer + except SerializerNotFound: + pass + + # Fall back to the hard-coded serializer class + logger.debug(f"Using serializer {self.serializer_class}") + return self.serializer_class + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + if not request.user.is_authenticated: + return + + # Restrict the view's QuerySet to allow only the permitted objects + action = HTTP_ACTIONS[request.method] + if action: + self.queryset = self.queryset.restrict(request.user, action) + + def dispatch(self, request, *args, **kwargs): + logger = logging.getLogger('netbox.api.views.ModelViewSet') + + try: + return super().dispatch(request, *args, **kwargs) + except ProtectedError as e: + protected_objects = list(e.protected_objects) + msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' + msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) + logger.warning(msg) + return self.finalize_response( + request, + Response({'detail': msg}, status=409), + *args, + **kwargs + ) + + def _validate_objects(self, instance): + """ + Check that the provided instance or list of instances are matched by the current queryset. This confirms that + any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. + """ + if type(instance) is list: + # Check that all instances are still included in the view's queryset + conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() + if conforming_count != len(instance): + raise ObjectDoesNotExist + else: + # Check that the instance is matched by the view's queryset + self.queryset.get(pk=instance.pk) + + def perform_create(self, serializer): + model = self.queryset.model + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Creating new {model._meta.verbose_name}") + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() + + def perform_update(self, serializer): + model = self.queryset.model + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() + + def perform_destroy(self, instance): + model = self.queryset.model + logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") + + return super().perform_destroy(instance) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 353c9d356..ed64a71a0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -461,18 +461,18 @@ REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', - 'netbox.api.TokenAuthentication', + 'netbox.api.authentication.TokenAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', ), - 'DEFAULT_PAGINATION_CLASS': 'netbox.api.OptionalLimitOffsetPagination', + 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( - 'netbox.api.TokenPermissions', + 'netbox.api.authentication.TokenPermissions', ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', - 'netbox.api.FormlessBrowsableAPIRenderer', + 'netbox.api.renderers.FormlessBrowsableAPIRenderer', ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', @@ -484,7 +484,7 @@ REST_FRAMEWORK = { # Custom operations 'bulk_destroy': 'bulk_delete', }, - 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', + 'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name', } diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py index 13c016c18..aaec27c1f 100644 --- a/netbox/secrets/api/nested_serializers.py +++ b/netbox/secrets/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers +from netbox.api import WritableNestedSerializer from secrets.models import Secret, SecretRole -from utilities.api import WritableNestedSerializer __all__ = [ 'NestedSecretRoleSerializer', diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 1fd3f19ef..b08b87bc5 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -6,7 +6,8 @@ from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from secrets.constants import SECRET_ASSIGNMENT_MODELS from secrets.models import Secret, SecretRole -from utilities.api import ContentTypeField, ValidatedModelSerializer, get_serializer_for_model +from netbox.api import ContentTypeField, ValidatedModelSerializer +from utilities.api import get_serializer_for_model from .nested_serializers import * diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 5ad05b09e..4000177b2 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 33cddea2b..940d23a0b 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -9,10 +9,10 @@ from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet +from netbox.api.views import ModelViewSet from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.api import ModelViewSet from . import serializers ERR_USERKEY_MISSING = "No UserKey found for the current user." diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 369d5eb1b..7b227c123 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers +from netbox.api import WritableNestedSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import WritableNestedSerializer __all__ = [ 'NestedTenantGroupSerializer', diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 4467b050b..05e83853e 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,8 +2,8 @@ from rest_framework import serializers from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ValidatedModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import ValidatedModelSerializer from .nested_serializers import * diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index ad4424005..32540879d 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 065d3a9f3..34be4991e 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,9 +4,9 @@ from circuits.models import Circuit from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet from ipam.models import IPAddress, Prefix, VLAN, VRF +from netbox.api.views import ModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index f1bcf3b37..3b43ca7c9 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -2,8 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers +from netbox.api import ContentTypeField, WritableNestedSerializer from users.models import ObjectPermission -from utilities.api import ContentTypeField, WritableNestedSerializer __all__ = [ 'NestedGroupSerializer', diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1f338d6e4..31e7915b8 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,8 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers +from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from users.models import ObjectPermission -from utilities.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from .nested_serializers import * diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index c52c6c87f..4176cc806 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index a3536e960..b799bee19 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -2,9 +2,9 @@ from django.contrib.auth.models import Group, User from django.db.models import Count from rest_framework.routers import APIRootView +from netbox.api.views import ModelViewSet from users import filters from users.models import ObjectPermission -from utilities.api import ModelViewSet from utilities.querysets import RestrictedQuerySet from . import serializers diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index e652656d7..958cdf9a3 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,41 +1,8 @@ -import logging -from collections import OrderedDict - -import pytz -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied -from django.db import transaction -from django.db.models import ManyToManyField, ProtectedError from django.urls import reverse -from rest_framework import mixins, serializers, status -from rest_framework.exceptions import APIException, ValidationError -from rest_framework.permissions import BasePermission -from rest_framework.relations import PrimaryKeyRelatedField, RelatedField -from rest_framework.response import Response -from rest_framework.routers import DefaultRouter -from rest_framework.viewsets import GenericViewSet +from rest_framework.utils import formatting -from .utils import dict_to_filter_params, dynamic_import - -HTTP_ACTIONS = { - 'GET': 'view', - 'OPTIONS': None, - 'HEAD': 'view', - 'POST': 'add', - 'PUT': 'change', - 'PATCH': 'change', - 'DELETE': 'delete', -} - - -class ServiceUnavailable(APIException): - status_code = 503 - default_detail = "Service temporarily unavailable, please try again later." - - -class SerializerNotFound(Exception): - pass +from netbox.api.exceptions import SerializerNotFound +from .utils import dynamic_import def get_serializer_for_model(model, prefix=''): @@ -63,459 +30,23 @@ def is_api_request(request): return request.path_info.startswith(api_path) -# -# Authentication -# - -class IsAuthenticatedOrLoginNotRequired(BasePermission): +def get_view_name(view, suffix=None): """ - Returns True if the user is authenticated or LOGIN_REQUIRED is False. + Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. """ - def has_permission(self, request, view): - if not settings.LOGIN_REQUIRED: - return True - return request.user.is_authenticated - - -# -# Fields -# - -class ChoiceField(serializers.Field): - """ - Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. - - :param choices: An iterable of choices in the form (value, key). - :param allow_blank: Allow blank values in addition to the listed choices. - """ - def __init__(self, choices, allow_blank=False, **kwargs): - self.choiceset = choices - self.allow_blank = allow_blank - self._choices = dict() - - # Unpack grouped choices - for k, v in choices: - if type(v) in [list, tuple]: - for k2, v2 in v: - self._choices[k2] = v2 - else: - self._choices[k] = v - - super().__init__(**kwargs) - - def validate_empty_values(self, data): - # Convert null to an empty string unless allow_null == True - if data is None: - if self.allow_null: - return True, None - else: - data = '' - return super().validate_empty_values(data) - - def to_representation(self, obj): - if obj is '': - return None - return OrderedDict([ - ('value', obj), - ('label', self._choices[obj]) - ]) - - def to_internal_value(self, data): - if data is '': - if self.allow_blank: - return data - raise ValidationError("This field may not be blank.") - - # Provide an explicit error message if the request is trying to write a dict or list - if isinstance(data, (dict, list)): - raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.') - - # Check for string representations of boolean/integer values - if hasattr(data, 'lower'): - if data.lower() == 'true': - data = True - elif data.lower() == 'false': - data = False - else: - try: - data = int(data) - except ValueError: - pass - - try: - if data in self._choices: - return data - except TypeError: # Input is an unhashable type - pass - - raise ValidationError(f"{data} is not a valid choice.") - - @property - def choices(self): - return self._choices - - -class ContentTypeField(RelatedField): - """ - Represent a ContentType as '.' - """ - default_error_messages = { - "does_not_exist": "Invalid content type: {content_type}", - "invalid": "Invalid value. Specify a content type as '.'.", - } - - def to_internal_value(self, data): - try: - app_label, model = data.split('.') - return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) - except ObjectDoesNotExist: - self.fail('does_not_exist', content_type=data) - except (TypeError, ValueError): - self.fail('invalid') - - def to_representation(self, obj): - return "{}.{}".format(obj.app_label, obj.model) - - -class TimeZoneField(serializers.Field): - """ - Represent a pytz time zone. - """ - def to_representation(self, obj): - return obj.zone if obj else None - - def to_internal_value(self, data): - if not data: - return "" - if data not in pytz.common_timezones: - raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data)) - return pytz.timezone(data) - - -class SerializedPKRelatedField(PrimaryKeyRelatedField): - """ - Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related - objects in a ManyToManyField while still allowing a set of primary keys to be written. - """ - def __init__(self, serializer, **kwargs): - self.serializer = serializer - self.pk_field = kwargs.pop('pk_field', None) - super().__init__(**kwargs) - - def to_representation(self, value): - return self.serializer(value, context={'request': self.context['request']}).data - - -# -# Serializers -# - -# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant -# way to enforce model validation on the serializer. -class ValidatedModelSerializer(serializers.ModelSerializer): - """ - Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. - """ - def validate(self, data): - - # Remove custom fields data and tags (if any) prior to model validation - attrs = data.copy() - attrs.pop('custom_fields', None) - attrs.pop('tags', None) - - # Skip ManyToManyFields - for field in self.Meta.model._meta.get_fields(): - if isinstance(field, ManyToManyField): - attrs.pop(field.name, None) - - # Run clean() on an instance of the model - if self.instance is None: - instance = self.Meta.model(**attrs) - else: - instance = self.instance - for k, v in attrs.items(): - setattr(instance, k, v) - instance.clean() - instance.validate_unique() - - return data - - -class WritableNestedSerializer(serializers.ModelSerializer): - """ - Returns a nested representation of an object on read, but accepts only a primary key on write. - """ - - def to_internal_value(self, data): - - if data is None: - return None - - # Dictionary of related object attributes - if isinstance(data, dict): - params = dict_to_filter_params(data) - queryset = self.Meta.model.objects - try: - return queryset.get(**params) - except ObjectDoesNotExist: - raise ValidationError( - "Related object not found using the provided attributes: {}".format(params) - ) - except MultipleObjectsReturned: - raise ValidationError( - "Multiple objects match the provided attributes: {}".format(params) - ) - except FieldError as e: - raise ValidationError(e) - - # Integer PK of related object - if isinstance(data, int): - pk = data - else: - try: - # PK might have been mistakenly passed as a string - pk = int(data) - except (TypeError, ValueError): - raise ValidationError( - "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " - "unrecognized value: {}".format(data) - ) - - # Look up object by PK - queryset = self.Meta.model.objects - try: - return queryset.get(pk=int(data)) - except ObjectDoesNotExist: - raise ValidationError( - "Related object not found using the provided numeric ID: {}".format(pk) - ) - - -class BulkOperationSerializer(serializers.Serializer): - id = serializers.IntegerField() - - -# -# Mixins -# - -class BulkUpdateModelMixin: - """ - Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one - or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set. - For example: - - PATCH /api/dcim/sites/ - [ - { - "id": 123, - "name": "New name" - }, - { - "id": 456, - "status": "planned" - } - ] - """ - def bulk_update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - serializer = BulkOperationSerializer(data=request.data, many=True) - serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( - pk__in=[o['id'] for o in serializer.data] - ) - - # Map update data by object ID - update_data = { - obj.pop('id'): obj for obj in request.data - } - - self.perform_bulk_update(qs, update_data, partial=partial) - - return Response(status=status.HTTP_200_OK) - - def perform_bulk_update(self, objects, update_data, partial): - with transaction.atomic(): - for obj in objects: - data = update_data.get(obj.id) - serializer = self.get_serializer(obj, data=data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - - def bulk_partial_update(self, request, *args, **kwargs): - kwargs['partial'] = True - return self.bulk_update(request, *args, **kwargs) - - -class BulkDestroyModelMixin: - """ - Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one - or more JSON objects, each specifying the numeric ID of an object to be deleted. For example: - - DELETE /api/dcim/sites/ - [ - {"id": 123}, - {"id": 456} - ] - """ - def bulk_destroy(self, request, *args, **kwargs): - serializer = BulkOperationSerializer(data=request.data, many=True) - serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( - pk__in=[o['id'] for o in serializer.data] - ) - - self.perform_bulk_destroy(qs) - - return Response(status=status.HTTP_204_NO_CONTENT) - - def perform_bulk_destroy(self, objects): - with transaction.atomic(): - for obj in objects: - self.perform_destroy(obj) - - -# -# Viewsets -# - -class ModelViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - BulkUpdateModelMixin, - BulkDestroyModelMixin, - GenericViewSet): - """ - Accept either a single object or a list of objects to create. - """ - def get_serializer(self, *args, **kwargs): - - # If a list of objects has been provided, initialize the serializer with many=True - if isinstance(kwargs.get('data', {}), list): - kwargs['many'] = True - - return super().get_serializer(*args, **kwargs) - - def get_serializer_class(self): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - - # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one - # exists - request = self.get_serializer_context()['request'] - if request.query_params.get('brief'): - logger.debug("Request is for 'brief' format; initializing nested serializer") - try: - serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') - logger.debug(f"Using serializer {serializer}") - return serializer - except SerializerNotFound: - pass - - # Fall back to the hard-coded serializer class - logger.debug(f"Using serializer {self.serializer_class}") - return self.serializer_class - - def initial(self, request, *args, **kwargs): - super().initial(request, *args, **kwargs) - - if not request.user.is_authenticated: - return - - # Restrict the view's QuerySet to allow only the permitted objects - action = HTTP_ACTIONS[request.method] - if action: - self.queryset = self.queryset.restrict(request.user, action) - - def dispatch(self, request, *args, **kwargs): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - - try: - return super().dispatch(request, *args, **kwargs) - except ProtectedError as e: - protected_objects = list(e.protected_objects) - msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' - msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) - logger.warning(msg) - return self.finalize_response( - request, - Response({'detail': msg}, status=409), - *args, - **kwargs - ) - - def _validate_objects(self, instance): - """ - Check that the provided instance or list of instances are matched by the current queryset. This confirms that - any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. - """ - if type(instance) is list: - # Check that all instances are still included in the view's queryset - conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() - if conforming_count != len(instance): - raise ObjectDoesNotExist - else: - # Check that the instance is matched by the view's queryset - self.queryset.get(pk=instance.pk) - - def perform_create(self, serializer): - model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Creating new {model._meta.verbose_name}") - - # Enforce object-level permissions on save() - try: - with transaction.atomic(): - instance = serializer.save() - self._validate_objects(instance) - except ObjectDoesNotExist: - raise PermissionDenied() - - def perform_update(self, serializer): - model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") - - # Enforce object-level permissions on save() - try: - with transaction.atomic(): - instance = serializer.save() - self._validate_objects(instance) - except ObjectDoesNotExist: - raise PermissionDenied() - - def perform_destroy(self, instance): - model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") - - return super().perform_destroy(instance) - - -# -# Routers -# - -class OrderedDefaultRouter(DefaultRouter): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Extend the list view mappings to support the DELETE operation - self.routes[0].mapping.update({ - 'put': 'bulk_update', - 'patch': 'bulk_partial_update', - 'delete': 'bulk_destroy', - }) - - def get_api_root_view(self, api_urls=None): - """ - Wrap DRF's DefaultRouter to return an alphabetized list of endpoints. - """ - api_root_dict = OrderedDict() - list_name = self.routes[0].name - for prefix, viewset, basename in sorted(self.registry, key=lambda x: x[0]): - api_root_dict[prefix] = list_name.format(basename=basename) - - return self.APIRootView.as_view(api_root_dict=api_root_dict) + if hasattr(view, 'queryset'): + # Determine the model name from the queryset. + name = view.queryset.model._meta.verbose_name + name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word + + else: + # Replicate DRF's built-in behavior. + name = view.__class__.__name__ + name = formatting.remove_trailing_string(name, 'View') + name = formatting.remove_trailing_string(name, 'ViewSet') + name = formatting.camelcase_to_spaces(name) + + if suffix: + name += ' ' + suffix + + return name diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 063d30016..1b931155f 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -6,7 +6,7 @@ from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField from extras.api.customfields import CustomFieldsDataField -from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer +from netbox.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index de56e6e6a..7763f0ef4 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim.models import Interface -from utilities.api import WritableNestedSerializer +from netbox.api import WritableNestedSerializer from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine __all__ = [ diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 711e1359e..3e977d94d 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -7,8 +7,8 @@ from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN +from netbox.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .nested_serializers import * diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index c40202a7d..d9df2fcfe 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,4 +1,4 @@ -from utilities.api import OrderedDefaultRouter +from netbox.api import OrderedDefaultRouter from . import views diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 9d210459e..a8462ff1d 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView from dcim.models import Device from extras.api.views import CustomFieldModelViewSet -from utilities.api import ModelViewSet +from netbox.api.views import ModelViewSet from utilities.utils import get_subquery from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface From 80c142ab7cf7eaa403e37cb167695455ff96d733 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 13 Oct 2020 16:57:45 -0400 Subject: [PATCH 138/291] Closes #4918: Add a REST API endpoint which returns NetBox's current operational status --- docs/release-notes/version-2.10.md | 4 ++- netbox/netbox/api/views.py | 46 ++++++++++++++++++++++++++++++ netbox/netbox/tests/test_api.py | 7 ++++- netbox/netbox/urls.py | 2 ++ netbox/netbox/views.py | 1 + 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 69df8c15d..d1564e5e1 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -46,6 +46,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services * [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `.` string +* [#4918](https://github.com/netbox-community/netbox/issues/4918) - Add a REST API endpoint (`/api/status/`) which returns NetBox's current operational status * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis @@ -63,7 +64,8 @@ All end-to-end cable paths are now cached using the new CablePath model. This al ### REST API Changes * Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete) -* Added `/extras/content-types/` endpoint for Django ContentTypes +* Added the `/extras/content-types/` endpoint for Django ContentTypes +* Added the `/status/` endpoint to convey NetBox's current status * circuits.CircuitTermination: * Added the `/trace/` endpoint * Replaced `connection_status` with `connected_endpoint_reachable` (boolean) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 3dd512205..887d6b5f8 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -1,11 +1,18 @@ import logging +import platform +from django import __version__ as DJANGO_VERSION +from django.apps import apps +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError +from django_rq.queues import get_connection from rest_framework import mixins, status from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet +from rq.worker import Worker from netbox.api import BulkOperationSerializer from netbox.api.exceptions import SerializerNotFound @@ -218,3 +225,42 @@ class ModelViewSet(mixins.CreateModelMixin, logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") return super().perform_destroy(instance) + + +# +# Views +# + +class StatusView(APIView): + """ + Provide a lightweight read-only endpoint for conveying NetBox's current operational status. + """ + permission_classes = [] + + def get(self, request): + # Gather the version number from all installed Django apps + installed_apps = {} + for app_config in apps.get_app_configs(): + app = app_config.module + version = getattr(app, 'VERSION', getattr(app, '__version__', None)) + if version: + if type(version) is tuple: + version = '.'.join(str(n) for n in version) + installed_apps[app_config.name] = version + installed_apps = {k: v for k, v in sorted(installed_apps.items())} + + # Gather installed plugins + plugins = {} + for plugin_name in settings.PLUGINS: + plugin_config = apps.get_app_config(plugin_name) + plugins[plugin_name] = getattr(plugin_config, 'version', None) + plugins = {k: v for k, v in sorted(plugins.items())} + + return Response({ + 'django-version': DJANGO_VERSION, + 'installed-apps': installed_apps, + 'netbox-version': settings.VERSION, + 'plugins': plugins, + 'python-version': platform.python_version(), + 'rq-workers-running': Worker.count(get_connection('default')), + }) diff --git a/netbox/netbox/tests/test_api.py b/netbox/netbox/tests/test_api.py index 0ee2d78dc..2ea12e72f 100644 --- a/netbox/netbox/tests/test_api.py +++ b/netbox/netbox/tests/test_api.py @@ -6,8 +6,13 @@ from utilities.testing import APITestCase class AppTest(APITestCase): def test_root(self): - url = reverse('api-root') response = self.client.get('{}?format=api'.format(url), **self.header) self.assertEqual(response.status_code, 200) + + def test_status(self): + url = reverse('api-status') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 4878729b0..250b1ac67 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -6,6 +6,7 @@ from drf_yasg import openapi from drf_yasg.views import get_schema_view from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns +from netbox.api.views import StatusView from netbox.views import APIRootView, HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site @@ -55,6 +56,7 @@ _patterns = [ path('api/tenancy/', include('tenancy.api.urls')), path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), + path('api/status/', StatusView.as_view(), name='api-status'), path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 161bfda74..baf014dbf 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -342,6 +342,7 @@ class APIRootView(APIView): ('ipam', reverse('ipam-api:api-root', request=request, format=format)), ('plugins', reverse('plugins-api:api-root', request=request, format=format)), ('secrets', reverse('secrets-api:api-root', request=request, format=format)), + ('status', reverse('api-status', request=request, format=format)), ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), ('users', reverse('users-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), From defade84e4461ba4647b80ff2b63ebb4c039d20d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 13 Oct 2020 17:18:13 -0400 Subject: [PATCH 139/291] Fix plugin name resolution --- netbox/netbox/api/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 887d6b5f8..86910089a 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -252,6 +252,7 @@ class StatusView(APIView): # Gather installed plugins plugins = {} for plugin_name in settings.PLUGINS: + plugin_name = plugin_name.rsplit('.', 1)[-1] plugin_config = apps.get_app_config(plugin_name) plugins[plugin_name] = getattr(plugin_config, 'version', None) plugins = {k: v for k, v in sorted(plugins.items())} From 143f3cc27c47aa3175320567052151548e69c992 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Oct 2020 15:23:23 -0400 Subject: [PATCH 140/291] #4711: Rename CustomField.obj_type to content_types --- netbox/extras/admin.py | 8 ++++---- netbox/extras/api/customfields.py | 6 +++--- netbox/extras/filters.py | 3 +-- netbox/extras/forms.py | 8 ++++---- ...choices.py => 0050_customfield_changes.py} | 6 ++++++ .../migrations/0051_migrate_customfields.py | 2 +- netbox/extras/models/customfields.py | 4 ++-- netbox/extras/models/models.py | 2 +- netbox/extras/signals.py | 4 ++-- netbox/extras/tests/test_changelog.py | 4 ++-- netbox/extras/tests/test_customfields.py | 20 +++++++++---------- netbox/utilities/tests/test_api.py | 2 +- 12 files changed, 37 insertions(+), 32 deletions(-) rename netbox/extras/migrations/{0050_customfield_add_choices.py => 0050_customfield_changes.py} (84%) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index a8b4c0eb8..1b2fe3d12 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -79,7 +79,7 @@ class CustomFieldForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - order_content_types(self.fields['obj_type']) + order_content_types(self.fields['content_types']) def clean(self): @@ -98,7 +98,7 @@ class CustomFieldAdmin(admin.ModelAdmin): 'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description', ] list_filter = [ - 'type', 'required', 'obj_type', + 'type', 'required', 'content_types', ] fieldsets = ( ('Custom Field', { @@ -106,7 +106,7 @@ class CustomFieldAdmin(admin.ModelAdmin): }), ('Assignment', { 'description': 'A custom field must be assigned to one or more object types.', - 'fields': ('obj_type',) + 'fields': ('content_types',) }), ('Choices', { 'description': 'A selection field must have two or more choices assigned to it.', @@ -115,7 +115,7 @@ class CustomFieldAdmin(admin.ModelAdmin): ) def models(self, obj): - return ', '.join([ct.name for ct in obj.obj_type.all()]) + return ', '.join([ct.name for ct in obj.content_types.all()]) # diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index a5f11fde6..d17090fc2 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -24,7 +24,7 @@ class CustomFieldDefaultValues: # Retrieve the CustomFields for the parent model content_type = ContentType.objects.get_for_model(self.model) - fields = CustomField.objects.filter(obj_type=content_type) + fields = CustomField.objects.filter(content_types=content_type) # Populate the default value for each CustomField value = {} @@ -52,7 +52,7 @@ class CustomFieldsDataField(Field): """ if not hasattr(self, '_custom_fields'): content_type = ContentType.objects.get_for_model(self.parent.Meta.model) - self._custom_fields = CustomField.objects.filter(obj_type=content_type) + self._custom_fields = CustomField.objects.filter(content_types=content_type) return self._custom_fields def to_representation(self, obj): @@ -132,7 +132,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): # Retrieve the set of CustomFields which apply to this type of object content_type = ContentType.objects.get_for_model(self.Meta.model) - fields = CustomField.objects.filter(obj_type=content_type) + fields = CustomField.objects.filter(content_types=content_type) # Populate CustomFieldValues for each instance from database if type(self.instance) in (list, tuple): diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 91f4d333b..d36ab28ad 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -65,9 +65,8 @@ class CustomFieldFilterSet(django_filters.FilterSet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - obj_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter( - obj_type=obj_type + content_types=ContentType.objects.get_for_model(self._meta.model) ).exclude( filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED ) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index d09ff64fe..eee54076b 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -35,7 +35,7 @@ class CustomFieldModelForm(forms.ModelForm): Append form fields for all CustomFields assigned to this model. """ # Append form fields; assign initial values if modifying and existing object - for cf in CustomField.objects.filter(obj_type=self.obj_type): + for cf in CustomField.objects.filter(content_types=self.obj_type): field_name = 'cf_{}'.format(cf.name) if self.instance.pk: self.fields[field_name] = cf.to_form_field(set_initial=False) @@ -60,7 +60,7 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): def _append_customfield_fields(self): # Append form fields - for cf in CustomField.objects.filter(obj_type=self.obj_type): + for cf in CustomField.objects.filter(content_types=self.obj_type): field_name = 'cf_{}'.format(cf.name) self.fields[field_name] = cf.to_form_field(for_csv_import=True) @@ -77,7 +77,7 @@ class CustomFieldBulkEditForm(BulkEditForm): self.obj_type = ContentType.objects.get_for_model(self.model) # Add all applicable CustomFields to the form - custom_fields = CustomField.objects.filter(obj_type=self.obj_type) + custom_fields = CustomField.objects.filter(content_types=self.obj_type) for cf in custom_fields: # Annotate non-required custom fields as nullable if not cf.required: @@ -96,7 +96,7 @@ class CustomFieldFilterForm(forms.Form): super().__init__(*args, **kwargs) # Add all applicable CustomFields to the form - custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude( + custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED ) for cf in custom_fields: diff --git a/netbox/extras/migrations/0050_customfield_add_choices.py b/netbox/extras/migrations/0050_customfield_changes.py similarity index 84% rename from netbox/extras/migrations/0050_customfield_add_choices.py rename to netbox/extras/migrations/0050_customfield_changes.py index 1ae63a2c3..923b5dbc4 100644 --- a/netbox/extras/migrations/0050_customfield_add_choices.py +++ b/netbox/extras/migrations/0050_customfield_changes.py @@ -32,4 +32,10 @@ class Migration(migrations.Migration): size=None ), ), + # Rename obj_type to content_types + migrations.RenameField( + model_name='customfield', + old_name='obj_type', + new_name='content_types', + ), ] diff --git a/netbox/extras/migrations/0051_migrate_customfields.py b/netbox/extras/migrations/0051_migrate_customfields.py index ac6d3816f..e4bcef1b3 100644 --- a/netbox/extras/migrations/0051_migrate_customfields.py +++ b/netbox/extras/migrations/0051_migrate_customfields.py @@ -56,7 +56,7 @@ class Migration(migrations.Migration): dependencies = [ ('circuits', '0020_custom_field_data'), ('dcim', '0117_custom_field_data'), - ('extras', '0050_customfield_add_choices'), + ('extras', '0050_customfield_changes'), ('ipam', '0038_custom_field_data'), ('secrets', '0010_custom_field_data'), ('tenancy', '0010_custom_field_data'), diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 73fc6a2e3..62912ee2c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -50,11 +50,11 @@ class CustomFieldManager(models.Manager): Return all CustomFields assigned to the given model. """ content_type = ContentType.objects.get_for_model(model._meta.concrete_model) - return self.get_queryset().filter(obj_type=content_type) + return self.get_queryset().filter(content_types=content_type) class CustomField(models.Model): - obj_type = models.ManyToManyField( + content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', verbose_name='Object(s)', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 8c88bb1da..065f517f5 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,6 @@ from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse -from django.template import Template, Context from django.urls import reverse from django.utils import timezone from rest_framework.utils.encoders import JSONEncoder @@ -32,6 +31,7 @@ class Webhook(models.Model): delete in NetBox. The request will contain a representation of the object, which the remote application can act on. Each Webhook can be limited to firing only on certain actions or certain object types. """ + # TODO: Rename obj_type to content_types (see #4711) obj_type = models.ManyToManyField( to=ContentType, related_name='webhooks', diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index d4e187b5c..0d6295e5b 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -89,10 +89,10 @@ def handle_cf_deleted(instance, **kwargs): """ Handle the cleanup of old custom field data when a CustomField is deleted. """ - instance.remove_stale_data(instance.obj_type.all()) + instance.remove_stale_data(instance.content_types.all()) -m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.obj_type.through) +m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through) pre_delete.connect(handle_cf_deleted, sender=CustomField) diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 5d1dee864..dbdbb5343 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -25,7 +25,7 @@ class ChangeLogViewTest(ModelViewTestCase): required=False ) cf.save() - cf.obj_type.set([ct]) + cf.content_types.set([ct]) def test_create_object(self): tags = self.create_tags('Tag 1', 'Tag 2') @@ -131,7 +131,7 @@ class ChangeLogAPITest(APITestCase): required=False ) cf.save() - cf.obj_type.set([ct]) + cf.content_types.set([ct]) # Create some tags tags = ( diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 38d904d41..7ebb7701e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -39,7 +39,7 @@ class CustomFieldTest(TestCase): # Create a custom field cf = CustomField(type=data['field_type'], name='my_field', required=False) cf.save() - cf.obj_type.set([obj_type]) + cf.content_types.set([obj_type]) cf.save() # Assign a value to the first Site @@ -72,7 +72,7 @@ class CustomFieldTest(TestCase): choices=['Option A', 'Option B', 'Option C'] ) cf.save() - cf.obj_type.set([obj_type]) + cf.content_types.set([obj_type]) cf.save() # Assign a value to the first Site @@ -100,7 +100,7 @@ class CustomFieldManagerTest(TestCase): content_type = ContentType.objects.get_for_model(Site) custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') custom_field.save() - custom_field.obj_type.set([content_type]) + custom_field.content_types.set([content_type]) def test_get_for_model(self): self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1) @@ -116,33 +116,33 @@ class CustomFieldAPITest(APITestCase): # Text custom field cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') cls.cf_text.save() - cls.cf_text.obj_type.set([content_type]) + cls.cf_text.content_types.set([content_type]) # Integer custom field cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) cls.cf_integer.save() - cls.cf_integer.obj_type.set([content_type]) + cls.cf_integer.content_types.set([content_type]) # Boolean custom field cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False) cls.cf_boolean.save() - cls.cf_boolean.obj_type.set([content_type]) + cls.cf_boolean.content_types.set([content_type]) # Date custom field cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01') cls.cf_date.save() - cls.cf_date.obj_type.set([content_type]) + cls.cf_date.content_types.set([content_type]) # URL custom field cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1') cls.cf_url.save() - cls.cf_url.obj_type.set([content_type]) + cls.cf_url.content_types.set([content_type]) # Select custom field cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) cls.cf_select.default = 'Foo' cls.cf_select.save() - cls.cf_select.obj_type.set([content_type]) + cls.cf_select.content_types.set([content_type]) # Create some sites cls.sites = ( @@ -429,7 +429,7 @@ class CustomFieldImportTest(TestCase): ) for cf in custom_fields: cf.save() - cf.obj_type.set([ContentType.objects.get_for_model(Site)]) + cf.content_types.set([ContentType.objects.get_for_model(Site)]) def test_import(self): """ diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 01d4ab8f3..2cc9accaa 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -131,7 +131,7 @@ class APIDocsTestCase(TestCase): content_type = ContentType.objects.get_for_model(Site) self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='test') self.cf_text.save() - self.cf_text.obj_type.set([content_type]) + self.cf_text.content_types.set([content_type]) self.cf_text.save() def test_api_docs(self): From e7d26ca5dc939e44689b319bb42c50901201b951 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Oct 2020 16:54:30 -0400 Subject: [PATCH 141/291] Move Cable and CablePath to cables.py --- netbox/dcim/models/__init__.py | 1 + netbox/dcim/models/cables.py | 387 +++++++++++++++++++++++++++++++++ netbox/dcim/models/devices.py | 375 +------------------------------- 3 files changed, 392 insertions(+), 371 deletions(-) create mode 100644 netbox/dcim/models/cables.py diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index fdd4d1bf5..513c07438 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1,3 +1,4 @@ +from .cables import * from .device_component_templates import * from .device_components import * from .devices import * diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py new file mode 100644 index 000000000..891230cb0 --- /dev/null +++ b/netbox/dcim/models/cables.py @@ -0,0 +1,387 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import models +from django.db.models import Sum +from django.urls import reverse +from taggit.managers import TaggableManager + +from dcim.choices import * +from dcim.constants import * +from dcim.fields import PathField +from dcim.utils import decompile_path_node, path_node_to_object +from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem +from extras.utils import extras_features +from utilities.fields import ColorField +from utilities.querysets import RestrictedQuerySet +from utilities.utils import to_meters +from .devices import Device +from .device_components import FrontPort, RearPort + + +__all__ = ( + 'Cable', + 'CablePath', +) + + +# +# Cables +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Cable(ChangeLoggedModel, CustomFieldModel): + """ + A physical connection between two endpoints. + """ + termination_a_type = models.ForeignKey( + to=ContentType, + limit_choices_to=CABLE_TERMINATION_MODELS, + on_delete=models.PROTECT, + related_name='+' + ) + termination_a_id = models.PositiveIntegerField() + termination_a = GenericForeignKey( + ct_field='termination_a_type', + fk_field='termination_a_id' + ) + termination_b_type = models.ForeignKey( + to=ContentType, + limit_choices_to=CABLE_TERMINATION_MODELS, + on_delete=models.PROTECT, + related_name='+' + ) + termination_b_id = models.PositiveIntegerField() + termination_b = GenericForeignKey( + ct_field='termination_b_type', + fk_field='termination_b_id' + ) + type = models.CharField( + max_length=50, + choices=CableTypeChoices, + blank=True + ) + status = models.CharField( + max_length=50, + choices=CableStatusChoices, + default=CableStatusChoices.STATUS_CONNECTED + ) + label = models.CharField( + max_length=100, + blank=True + ) + color = ColorField( + blank=True + ) + length = models.PositiveSmallIntegerField( + blank=True, + null=True + ) + length_unit = models.CharField( + max_length=50, + choices=CableLengthUnitChoices, + blank=True, + ) + # Stores the normalized length (in meters) for database ordering + _abs_length = models.DecimalField( + max_digits=10, + decimal_places=4, + blank=True, + null=True + ) + # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by + # their associated Devices. + _termination_a_device = models.ForeignKey( + to=Device, + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + _termination_b_device = models.ForeignKey( + to=Device, + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = [ + 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', + 'color', 'length', 'length_unit', + ] + + class Meta: + ordering = ['pk'] + unique_together = ( + ('termination_a_type', 'termination_a_id'), + ('termination_b_type', 'termination_b_id'), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # A copy of the PK to be used by __str__ in case the object is deleted + self._pk = self.pk + + # Cache the original status so we can check later if it's been changed + self._orig_status = self.status + + @classmethod + def from_db(cls, db, field_names, values): + """ + Cache the original A and B terminations of existing Cable instances for later reference inside clean(). + """ + instance = super().from_db(db, field_names, values) + + instance._orig_termination_a_type_id = instance.termination_a_type_id + instance._orig_termination_a_id = instance.termination_a_id + instance._orig_termination_b_type_id = instance.termination_b_type_id + instance._orig_termination_b_id = instance.termination_b_id + + return instance + + def __str__(self): + return self.label or '#{}'.format(self._pk) + + def get_absolute_url(self): + return reverse('dcim:cable', args=[self.pk]) + + def clean(self): + from circuits.models import CircuitTermination + + # Validate that termination A exists + if not hasattr(self, 'termination_a_type'): + raise ValidationError('Termination A type has not been specified') + try: + self.termination_a_type.model_class().objects.get(pk=self.termination_a_id) + except ObjectDoesNotExist: + raise ValidationError({ + 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type) + }) + + # Validate that termination B exists + if not hasattr(self, 'termination_b_type'): + raise ValidationError('Termination B type has not been specified') + try: + self.termination_b_type.model_class().objects.get(pk=self.termination_b_id) + except ObjectDoesNotExist: + raise ValidationError({ + 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type) + }) + + # If editing an existing Cable instance, check that neither termination has been modified. + if self.pk: + err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.' + if ( + self.termination_a_type_id != self._orig_termination_a_type_id or + self.termination_a_id != self._orig_termination_a_id + ): + raise ValidationError({ + 'termination_a': err_msg + }) + if ( + self.termination_b_type_id != self._orig_termination_b_type_id or + self.termination_b_id != self._orig_termination_b_id + ): + raise ValidationError({ + 'termination_b': err_msg + }) + + type_a = self.termination_a_type.model + type_b = self.termination_b_type.model + + # Validate interface types + if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format( + self.termination_a.get_type_display() + ) + }) + if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format( + self.termination_b.get_type_display() + ) + }) + + # Check that termination types are compatible + if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): + raise ValidationError( + f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" + ) + + # Check that a RearPort with multiple positions isn't connected to an endpoint + # or a RearPort with a different number of positions. + for term_a, term_b in [ + (self.termination_a, self.termination_b), + (self.termination_b, self.termination_a) + ]: + if isinstance(term_a, RearPort) and term_a.positions > 1: + if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)): + raise ValidationError( + "Rear ports with multiple positions may only be connected to other pass-through ports" + ) + if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions: + raise ValidationError( + f"{term_a} of {term_a.device} has {term_a.positions} position(s) but " + f"{term_b} of {term_b.device} has {term_b.positions}. " + f"Both terminations must have the same number of positions." + ) + + # A termination point cannot be connected to itself + if self.termination_a == self.termination_b: + raise ValidationError(f"Cannot connect {self.termination_a_type} to itself") + + # A front port cannot be connected to its corresponding rear port + if ( + type_a in ['frontport', 'rearport'] and + type_b in ['frontport', 'rearport'] and + ( + getattr(self.termination_a, 'rear_port', None) == self.termination_b or + getattr(self.termination_b, 'rear_port', None) == self.termination_a + ) + ): + raise ValidationError("A front port cannot be connected to it corresponding rear port") + + # Check for an existing Cable connected to either termination object + if self.termination_a.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_a, self.termination_a.cable_id + )) + if self.termination_b.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_b, self.termination_b.cable_id + )) + + # Validate length and length_unit + if self.length is not None and not self.length_unit: + raise ValidationError("Must specify a unit when setting a cable length") + elif self.length is None: + self.length_unit = '' + + def save(self, *args, **kwargs): + + # Store the given length (if any) in meters for use in database ordering + if self.length and self.length_unit: + self._abs_length = to_meters(self.length, self.length_unit) + else: + self._abs_length = None + + # Store the parent Device for the A and B terminations (if applicable) to enable filtering + if hasattr(self.termination_a, 'device'): + self._termination_a_device = self.termination_a.device + if hasattr(self.termination_b, 'device'): + self._termination_b_device = self.termination_b.device + + super().save(*args, **kwargs) + + # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) + self._pk = self.pk + + def to_csv(self): + return ( + '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model), + self.termination_a_id, + '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model), + self.termination_b_id, + self.get_type_display(), + self.get_status_display(), + self.label, + self.color, + self.length, + self.length_unit, + ) + + def get_status_class(self): + return CableStatusChoices.CSS_CLASSES.get(self.status) + + def get_compatible_types(self): + """ + Return all termination types compatible with termination A. + """ + if self.termination_a is None: + return + return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] + + +class CablePath(models.Model): + """ + A CablePath instance represents the physical path from an origin to a destination, including all intermediate + elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do + not terminate on a PathEndpoint). + + `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the + path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following + topology: + + 1 2 3 + Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B + + This path would be expressed as: + + CablePath( + origin = Interface A + destination = Interface B + path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3] + ) + + `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of + "connected". + """ + origin_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+' + ) + origin_id = models.PositiveIntegerField() + origin = GenericForeignKey( + ct_field='origin_type', + fk_field='origin_id' + ) + destination_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + destination_id = models.PositiveIntegerField( + blank=True, + null=True + ) + destination = GenericForeignKey( + ct_field='destination_type', + fk_field='destination_id' + ) + path = PathField() + is_active = models.BooleanField( + default=False + ) + + class Meta: + unique_together = ('origin_type', 'origin_id') + + def __str__(self): + path = ', '.join([str(path_node_to_object(node)) for node in self.path]) + return f"Path #{self.pk}: {self.origin} to {self.destination} via ({path})" + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + # Record a direct reference to this CablePath on its originating object + model = self.origin._meta.model + model.objects.filter(pk=self.origin.pk).update(_path=self.pk) + + def get_total_length(self): + """ + Return the sum of the length of each cable in the path. + """ + cable_ids = [ + # Starting from the first element, every third element in the path should be a Cable + decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) + ] + return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total'] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index b44146b99..98e4045f1 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -2,32 +2,26 @@ from collections import OrderedDict import yaml from django.conf import settings -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, ProtectedError, Sum +from django.db.models import F, ProtectedError from django.urls import reverse from django.utils.safestring import mark_safe from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from dcim.fields import PathField -from dcim.utils import decompile_path_node, path_node_to_object from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet -from utilities.utils import to_meters from .device_components import * __all__ = ( - 'Cable', - 'CablePath', 'Device', 'DeviceRole', 'DeviceType', @@ -856,6 +850,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ Return a QuerySet or PK list matching all Cables connected to a component of this Device. """ + from .cables import Cable cable_pks = [] for component_model in [ ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort @@ -877,368 +872,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): return DeviceStatusChoices.CSS_CLASSES.get(self.status) -# -# Cables -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cable(ChangeLoggedModel, CustomFieldModel): - """ - A physical connection between two endpoints. - """ - termination_a_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - on_delete=models.PROTECT, - related_name='+' - ) - termination_a_id = models.PositiveIntegerField() - termination_a = GenericForeignKey( - ct_field='termination_a_type', - fk_field='termination_a_id' - ) - termination_b_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - on_delete=models.PROTECT, - related_name='+' - ) - termination_b_id = models.PositiveIntegerField() - termination_b = GenericForeignKey( - ct_field='termination_b_type', - fk_field='termination_b_id' - ) - type = models.CharField( - max_length=50, - choices=CableTypeChoices, - blank=True - ) - status = models.CharField( - max_length=50, - choices=CableStatusChoices, - default=CableStatusChoices.STATUS_CONNECTED - ) - label = models.CharField( - max_length=100, - blank=True - ) - color = ColorField( - blank=True - ) - length = models.PositiveSmallIntegerField( - blank=True, - null=True - ) - length_unit = models.CharField( - max_length=50, - choices=CableLengthUnitChoices, - blank=True, - ) - # Stores the normalized length (in meters) for database ordering - _abs_length = models.DecimalField( - max_digits=10, - decimal_places=4, - blank=True, - null=True - ) - # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by - # their associated Devices. - _termination_a_device = models.ForeignKey( - to=Device, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - _termination_b_device = models.ForeignKey( - to=Device, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = [ - 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', - 'color', 'length', 'length_unit', - ] - - class Meta: - ordering = ['pk'] - unique_together = ( - ('termination_a_type', 'termination_a_id'), - ('termination_b_type', 'termination_b_id'), - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # A copy of the PK to be used by __str__ in case the object is deleted - self._pk = self.pk - - # Cache the original status so we can check later if it's been changed - self._orig_status = self.status - - @classmethod - def from_db(cls, db, field_names, values): - """ - Cache the original A and B terminations of existing Cable instances for later reference inside clean(). - """ - instance = super().from_db(db, field_names, values) - - instance._orig_termination_a_type_id = instance.termination_a_type_id - instance._orig_termination_a_id = instance.termination_a_id - instance._orig_termination_b_type_id = instance.termination_b_type_id - instance._orig_termination_b_id = instance.termination_b_id - - return instance - - def __str__(self): - return self.label or '#{}'.format(self._pk) - - def get_absolute_url(self): - return reverse('dcim:cable', args=[self.pk]) - - def clean(self): - from circuits.models import CircuitTermination - - # Validate that termination A exists - if not hasattr(self, 'termination_a_type'): - raise ValidationError('Termination A type has not been specified') - try: - self.termination_a_type.model_class().objects.get(pk=self.termination_a_id) - except ObjectDoesNotExist: - raise ValidationError({ - 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type) - }) - - # Validate that termination B exists - if not hasattr(self, 'termination_b_type'): - raise ValidationError('Termination B type has not been specified') - try: - self.termination_b_type.model_class().objects.get(pk=self.termination_b_id) - except ObjectDoesNotExist: - raise ValidationError({ - 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type) - }) - - # If editing an existing Cable instance, check that neither termination has been modified. - if self.pk: - err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.' - if ( - self.termination_a_type_id != self._orig_termination_a_type_id or - self.termination_a_id != self._orig_termination_a_id - ): - raise ValidationError({ - 'termination_a': err_msg - }) - if ( - self.termination_b_type_id != self._orig_termination_b_type_id or - self.termination_b_id != self._orig_termination_b_id - ): - raise ValidationError({ - 'termination_b': err_msg - }) - - type_a = self.termination_a_type.model - type_b = self.termination_b_type.model - - # Validate interface types - if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format( - self.termination_a.get_type_display() - ) - }) - if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format( - self.termination_b.get_type_display() - ) - }) - - # Check that termination types are compatible - if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): - raise ValidationError( - f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" - ) - - # Check that a RearPort with multiple positions isn't connected to an endpoint - # or a RearPort with a different number of positions. - for term_a, term_b in [ - (self.termination_a, self.termination_b), - (self.termination_b, self.termination_a) - ]: - if isinstance(term_a, RearPort) and term_a.positions > 1: - if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)): - raise ValidationError( - "Rear ports with multiple positions may only be connected to other pass-through ports" - ) - if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions: - raise ValidationError( - f"{term_a} of {term_a.device} has {term_a.positions} position(s) but " - f"{term_b} of {term_b.device} has {term_b.positions}. " - f"Both terminations must have the same number of positions." - ) - - # A termination point cannot be connected to itself - if self.termination_a == self.termination_b: - raise ValidationError(f"Cannot connect {self.termination_a_type} to itself") - - # A front port cannot be connected to its corresponding rear port - if ( - type_a in ['frontport', 'rearport'] and - type_b in ['frontport', 'rearport'] and - ( - getattr(self.termination_a, 'rear_port', None) == self.termination_b or - getattr(self.termination_b, 'rear_port', None) == self.termination_a - ) - ): - raise ValidationError("A front port cannot be connected to it corresponding rear port") - - # Check for an existing Cable connected to either termination object - if self.termination_a.cable not in (None, self): - raise ValidationError("{} already has a cable attached (#{})".format( - self.termination_a, self.termination_a.cable_id - )) - if self.termination_b.cable not in (None, self): - raise ValidationError("{} already has a cable attached (#{})".format( - self.termination_b, self.termination_b.cable_id - )) - - # Validate length and length_unit - if self.length is not None and not self.length_unit: - raise ValidationError("Must specify a unit when setting a cable length") - elif self.length is None: - self.length_unit = '' - - def save(self, *args, **kwargs): - - # Store the given length (if any) in meters for use in database ordering - if self.length and self.length_unit: - self._abs_length = to_meters(self.length, self.length_unit) - else: - self._abs_length = None - - # Store the parent Device for the A and B terminations (if applicable) to enable filtering - if hasattr(self.termination_a, 'device'): - self._termination_a_device = self.termination_a.device - if hasattr(self.termination_b, 'device'): - self._termination_b_device = self.termination_b.device - - super().save(*args, **kwargs) - - # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) - self._pk = self.pk - - def to_csv(self): - return ( - '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model), - self.termination_a_id, - '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model), - self.termination_b_id, - self.get_type_display(), - self.get_status_display(), - self.label, - self.color, - self.length, - self.length_unit, - ) - - def get_status_class(self): - return CableStatusChoices.CSS_CLASSES.get(self.status) - - def get_compatible_types(self): - """ - Return all termination types compatible with termination A. - """ - if self.termination_a is None: - return - return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] - - -class CablePath(models.Model): - """ - A CablePath instance represents the physical path from an origin to a destination, including all intermediate - elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do - not terminate on a PathEndpoint). - - `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the - path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following - topology: - - 1 2 3 - Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B - - This path would be expressed as: - - CablePath( - origin = Interface A - destination = Interface B - path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3] - ) - - `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of - "connected". - """ - origin_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+' - ) - origin_id = models.PositiveIntegerField() - origin = GenericForeignKey( - ct_field='origin_type', - fk_field='origin_id' - ) - destination_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - destination_id = models.PositiveIntegerField( - blank=True, - null=True - ) - destination = GenericForeignKey( - ct_field='destination_type', - fk_field='destination_id' - ) - path = PathField() - is_active = models.BooleanField( - default=False - ) - - class Meta: - unique_together = ('origin_type', 'origin_id') - - def __str__(self): - path = ', '.join([str(path_node_to_object(node)) for node in self.path]) - return f"Path #{self.pk}: {self.origin} to {self.destination} via ({path})" - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - - # Record a direct reference to this CablePath on its originating object - model = self.origin._meta.model - model.objects.filter(pk=self.origin.pk).update(_path=self.pk) - - def get_total_length(self): - """ - Return the sum of the length of each cable in the path. - """ - cable_ids = [ - # Starting from the first element, every third element in the path should be a Cable - decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) - ] - return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total'] - - # # Virtual chassis # From 8781cf1c57405ee7248a8fff4cbdadec74bf6e7c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Oct 2020 15:06:01 -0400 Subject: [PATCH 142/291] Closes #609: Add min/max value and regex validation for custom fields --- docs/release-notes/version-2.10.md | 1 + netbox/extras/admin.py | 3 ++ netbox/extras/api/customfields.py | 10 ++++ .../migrations/0050_customfield_changes.py | 20 ++++++- netbox/extras/models/customfields.py | 53 ++++++++++++++++++- netbox/extras/tests/test_customfields.py | 43 +++++++++++++-- netbox/utilities/validators.py | 12 +++++ 7 files changed, 135 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index d1564e5e1..f8288cd6a 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -42,6 +42,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al ### Enhancements +* [#609](https://github.com/netbox-community/netbox/issues/609) - Add min/max value and regex validation for custom fields * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 1b2fe3d12..e1ffc044a 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -108,6 +108,9 @@ class CustomFieldAdmin(admin.ModelAdmin): 'description': 'A custom field must be assigned to one or more object types.', 'fields': ('content_types',) }), + ('Validation Rules', { + 'fields': ('validation_minimum', 'validation_maximum', 'validation_regex') + }), ('Choices', { 'description': 'A selection field must have two or more choices assigned to it.', 'fields': ('choices',) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index d17090fc2..8cb7dd5ba 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,3 +1,4 @@ +import re from datetime import datetime from django.contrib.contenttypes.models import ContentType @@ -77,12 +78,21 @@ class CustomFieldsDataField(Field): # Data validation if value not in [None, '']: + # Validate text field + if cf.type == CustomFieldTypeChoices.TYPE_TEXT and cf.validation_regex: + if not re.match(cf.validation_regex, value): + raise ValidationError(f"{field_name}: Value must match regex {cf.validation_regex}") + # Validate integer if cf.type == CustomFieldTypeChoices.TYPE_INTEGER: try: int(value) except ValueError: raise ValidationError(f"Invalid value for integer field {field_name}: {value}") + if cf.validation_minimum is not None and value < cf.validation_minimum: + raise ValidationError(f"{field_name}: Value must be at least {cf.validation_minimum}") + if cf.validation_maximum is not None and value > cf.validation_maximum: + raise ValidationError(f"{field_name}: Value must not exceed {cf.validation_maximum}") # Validate boolean if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: diff --git a/netbox/extras/migrations/0050_customfield_changes.py b/netbox/extras/migrations/0050_customfield_changes.py index 923b5dbc4..7d0a4c575 100644 --- a/netbox/extras/migrations/0050_customfield_changes.py +++ b/netbox/extras/migrations/0050_customfield_changes.py @@ -1,6 +1,8 @@ import django.contrib.postgres.fields +import django.core.validators from django.db import migrations, models -import django.db.models.deletion + +import utilities.validators class Migration(migrations.Migration): @@ -38,4 +40,20 @@ class Migration(migrations.Migration): old_name='obj_type', new_name='content_types', ), + # Add validation fields + migrations.AddField( + model_name='customfield', + name='validation_maximum', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='customfield', + name='validation_minimum', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='customfield', + name='validation_regex', + field=models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex]), + ), ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 62912ee2c..5c403128c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -4,10 +4,12 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder -from django.core.validators import ValidationError +from django.core.validators import RegexValidator, ValidationError from django.db import models +from django.utils.safestring import mark_safe from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice +from utilities.validators import validate_regex from extras.choices import * from extras.utils import FeatureQuery @@ -101,6 +103,25 @@ class CustomField(models.Model): default=100, help_text='Fields with higher weights appear lower in a form.' ) + validation_minimum = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Minimum value', + help_text='Minimum allowed value (for numeric fields)' + ) + validation_maximum = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Maximum value', + help_text='Maximum allowed value (for numeric fields)' + ) + validation_regex = models.CharField( + blank=True, + validators=[validate_regex], + max_length=500, + verbose_name='Validation regex', + help_text='Regular expression to enforce on text field values' + ) choices = ArrayField( base_field=models.CharField(max_length=100), blank=True, @@ -128,6 +149,22 @@ class CustomField(models.Model): obj.save() def clean(self): + # Minimum/maximum values can be set only for numeric fields + if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: + raise ValidationError({ + 'validation_minimum': "A minimum value may be set only for numeric fields" + }) + if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: + raise ValidationError({ + 'validation_maximum': "A maximum value may be set only for numeric fields" + }) + + # Regex validation can be set only for text fields + if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT: + raise ValidationError({ + 'validation_regex': "Regular expression validation is supported only for text and URL fields" + }) + # Choices can be set only on selection fields if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: raise ValidationError({ @@ -153,7 +190,12 @@ class CustomField(models.Model): # Integer if self.type == CustomFieldTypeChoices.TYPE_INTEGER: - field = forms.IntegerField(required=required, initial=initial) + field = forms.IntegerField( + required=required, + initial=initial, + min_value=self.validation_minimum, + max_value=self.validation_maximum + ) # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: @@ -196,6 +238,13 @@ class CustomField(models.Model): # Text else: field = forms.CharField(max_length=255, required=required, initial=initial) + if self.validation_regex: + field.validators = [ + RegexValidator( + regex=self.validation_regex, + message=mark_safe(f"Values must match this regex: {self.validation_regex}") + ) + ] field.model = self field.label = str(self) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7ebb7701e..ca33c4697 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -21,7 +21,6 @@ class CustomFieldTest(TestCase): ]) def test_simple_fields(self): - DATA = ( {'field_type': CustomFieldTypeChoices.TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''}, {'field_type': CustomFieldTypeChoices.TYPE_INTEGER, 'field_value': 0, 'empty_value': None}, @@ -40,7 +39,6 @@ class CustomFieldTest(TestCase): cf = CustomField(type=data['field_type'], name='my_field', required=False) cf.save() cf.content_types.set([obj_type]) - cf.save() # Assign a value to the first Site site = Site.objects.first() @@ -61,7 +59,6 @@ class CustomFieldTest(TestCase): cf.delete() def test_select_field(self): - obj_type = ContentType.objects.get_for_model(Site) # Create a custom field @@ -73,7 +70,6 @@ class CustomFieldTest(TestCase): ) cf.save() cf.content_types.set([obj_type]) - cf.save() # Assign a value to the first Site site = Site.objects.first() @@ -409,6 +405,45 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) + def test_minimum_maximum_values_validation(self): + url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + self.add_permissions('dcim.change_site') + + self.cf_integer.validation_minimum = 10 + self.cf_integer.validation_maximum = 20 + self.cf_integer.save() + + data = {'custom_fields': {'number_field': 9}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'number_field': 21}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'number_field': 15}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + def test_regex_validation(self): + url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + self.add_permissions('dcim.change_site') + + self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters + self.cf_text.save() + + data = {'custom_fields': {'text_field': 'ABC123'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'text_field': 'abc'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + data = {'custom_fields': {'text_field': 'ABC'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + class CustomFieldImportTest(TestCase): user_permissions = ( diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index 517a567a9..b087b0867 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,6 +1,7 @@ import re from django.conf import settings +from django.core.exceptions import ValidationError from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator @@ -29,3 +30,14 @@ class ExclusionValidator(BaseValidator): def compare(self, a, b): return a in b + + +def validate_regex(value): + """ + Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex + to validate a value.) + """ + try: + re.compile(value) + except re.error: + raise ValidationError(f"{value} is not a valid regular expression.") From c9c8d337a0dcb96832c790841c7cb6f29a2420d0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Oct 2020 15:37:34 -0400 Subject: [PATCH 143/291] Closes #5011: Standardized name field lengths across all models --- docs/release-notes/version-2.10.md | 1 + .../0024_standardize_name_length.py | 38 +++++++ netbox/circuits/models.py | 8 +- .../0122_standardize_name_length.py | 98 +++++++++++++++++++ netbox/dcim/models/devices.py | 16 +-- netbox/dcim/models/power.py | 4 +- netbox/dcim/models/racks.py | 11 ++- netbox/dcim/models/sites.py | 6 +- .../0042_standardize_name_length.py | 53 ++++++++++ netbox/ipam/models.py | 16 +-- .../0012_standardize_name_length.py | 23 +++++ netbox/secrets/models.py | 3 +- .../0011_standardize_name_length.py | 33 +++++++ netbox/tenancy/models.py | 6 +- .../0019_standardize_name_length.py | 33 +++++++ netbox/virtualization/models.py | 6 +- 16 files changed, 327 insertions(+), 28 deletions(-) create mode 100644 netbox/circuits/migrations/0024_standardize_name_length.py create mode 100644 netbox/dcim/migrations/0122_standardize_name_length.py create mode 100644 netbox/ipam/migrations/0042_standardize_name_length.py create mode 100644 netbox/secrets/migrations/0012_standardize_name_length.py create mode 100644 netbox/tenancy/migrations/0011_standardize_name_length.py create mode 100644 netbox/virtualization/migrations/0019_standardize_name_length.py diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index f8288cd6a..e5734ea8e 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -60,6 +60,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * [#4360](https://github.com/netbox-community/netbox/issues/4360) - Remove support for the Django template language from export templates * [#4878](https://github.com/netbox-community/netbox/issues/4878) - Custom field data is now stored directly on each object * [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method +* [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models * [#5225](https://github.com/netbox-community/netbox/issues/5225) - Circuit termination port speed is now an optional field ### REST API Changes diff --git a/netbox/circuits/migrations/0024_standardize_name_length.py b/netbox/circuits/migrations/0024_standardize_name_length.py new file mode 100644 index 000000000..8d0ae48e3 --- /dev/null +++ b/netbox/circuits/migrations/0024_standardize_name_length.py @@ -0,0 +1,38 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0023_circuittermination_port_speed_optional'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='cid', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='circuittype', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='circuittype', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='provider', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='provider', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index a4eed83f0..3d6d5d232 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -27,10 +27,11 @@ class Provider(ChangeLoggedModel, CustomFieldModel): stores information pertinent to the user's relationship with the Provider. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) asn = ASNField( @@ -98,10 +99,11 @@ class CircuitType(ChangeLoggedModel): "Long Haul," "Metro," or "Out-of-Band". """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( @@ -138,7 +140,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): in Kbps. """ cid = models.CharField( - max_length=50, + max_length=100, verbose_name='Circuit ID' ) provider = models.ForeignKey( diff --git a/netbox/dcim/migrations/0122_standardize_name_length.py b/netbox/dcim/migrations/0122_standardize_name_length.py new file mode 100644 index 000000000..6c805f2ee --- /dev/null +++ b/netbox/dcim/migrations/0122_standardize_name_length.py @@ -0,0 +1,98 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0121_cablepath'), + ] + + operations = [ + migrations.AlterField( + model_name='devicerole', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='devicerole', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='devicetype', + name='model', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='devicetype', + name='slug', + field=models.SlugField(max_length=100), + ), + migrations.AlterField( + model_name='manufacturer', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='manufacturer', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='powerpanel', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='rack', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='rackgroup', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='rackgroup', + name='slug', + field=models.SlugField(max_length=100), + ), + migrations.AlterField( + model_name='rackrole', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='rackrole', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='region', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='region', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='site', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='site', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 98e4045f1..2c067bd86 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -41,10 +41,11 @@ class Manufacturer(ChangeLoggedModel): A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( @@ -95,9 +96,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): related_name='device_types' ) model = models.CharField( - max_length=50 + max_length=100 + ) + slug = models.SlugField( + max_length=100 ) - slug = models.SlugField() part_number = models.CharField( max_length=50, blank=True, @@ -340,10 +343,11 @@ class DeviceRole(ChangeLoggedModel): virtual machines as well. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) color = ColorField( @@ -390,8 +394,8 @@ class Platform(ChangeLoggedModel): unique=True ) slug = models.SlugField( - unique=True, - max_length=100 + max_length=100, + unique=True ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index f869a3af4..8dae9074a 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -38,7 +38,7 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): null=True ) name = models.CharField( - max_length=50 + max_length=100 ) tags = TaggableManager(through=TaggedItem) @@ -89,7 +89,7 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo null=True ) name = models.CharField( - max_length=50 + max_length=100 ) status = models.CharField( max_length=50, diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 78c72f503..b5dd92069 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -47,9 +47,11 @@ class RackGroup(MPTTModel, ChangeLoggedModel): campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor. """ name = models.CharField( - max_length=50 + max_length=100 + ) + slug = models.SlugField( + max_length=100 ) - slug = models.SlugField() site = models.ForeignKey( to='dcim.Site', on_delete=models.CASCADE, @@ -118,10 +120,11 @@ class RackRole(ChangeLoggedModel): Racks can be organized by functional role, similar to Devices. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) color = ColorField( @@ -161,7 +164,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): Each Rack is assigned to a Site and (optionally) a RackGroup. """ name = models.CharField( - max_length=50 + max_length=100 ) _name = NaturalOrderingField( target_field='name', diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 0adc9aac5..923b33124 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -39,10 +39,11 @@ class Region(MPTTModel, ChangeLoggedModel): db_index=True ) name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( @@ -98,7 +99,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) _name = NaturalOrderingField( @@ -107,6 +108,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): blank=True ) slug = models.SlugField( + max_length=100, unique=True ) status = models.CharField( diff --git a/netbox/ipam/migrations/0042_standardize_name_length.py b/netbox/ipam/migrations/0042_standardize_name_length.py new file mode 100644 index 000000000..09bfdda6e --- /dev/null +++ b/netbox/ipam/migrations/0042_standardize_name_length.py @@ -0,0 +1,53 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0041_routetarget'), + ] + + operations = [ + migrations.AlterField( + model_name='rir', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='rir', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='role', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='role', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='service', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='vlangroup', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='vlangroup', + name='slug', + field=models.SlugField(max_length=100), + ), + migrations.AlterField( + model_name='vrf', + name='name', + field=models.CharField(max_length=100), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 11ce180a4..f6414d5d7 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -46,7 +46,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel): are said to exist in the "global" table.) """ name = models.CharField( - max_length=50 + max_length=100 ) rd = models.CharField( max_length=VRF_RD_MAX_LENGTH, @@ -168,10 +168,11 @@ class RIR(ChangeLoggedModel): space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) is_private = models.BooleanField( @@ -313,10 +314,11 @@ class Role(ChangeLoggedModel): "Management." """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) weight = models.PositiveSmallIntegerField( @@ -826,9 +828,11 @@ class VLANGroup(ChangeLoggedModel): A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. """ name = models.CharField( - max_length=50 + max_length=100 + ) + slug = models.SlugField( + max_length=100 ) - slug = models.SlugField() site = models.ForeignKey( to='dcim.Site', on_delete=models.PROTECT, @@ -1021,7 +1025,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): blank=True ) name = models.CharField( - max_length=30 + max_length=100 ) protocol = models.CharField( max_length=50, diff --git a/netbox/secrets/migrations/0012_standardize_name_length.py b/netbox/secrets/migrations/0012_standardize_name_length.py new file mode 100644 index 000000000..e41d81761 --- /dev/null +++ b/netbox/secrets/migrations/0012_standardize_name_length.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0011_secret_generic_assignments'), + ] + + operations = [ + migrations.AlterField( + model_name='secretrole', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='secretrole', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index f5508d47d..862fd4f2d 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -241,10 +241,11 @@ class SecretRole(ChangeLoggedModel): such as "Login Credentials" or "SNMP Communities." """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( diff --git a/netbox/tenancy/migrations/0011_standardize_name_length.py b/netbox/tenancy/migrations/0011_standardize_name_length.py new file mode 100644 index 000000000..1e29a0f5e --- /dev/null +++ b/netbox/tenancy/migrations/0011_standardize_name_length.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0010_custom_field_data'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='tenant', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='tenantgroup', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='tenantgroup', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 5a6108e09..3ba644c09 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -21,10 +21,11 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): An arbitrary collection of Tenants. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) parent = TreeForeignKey( @@ -81,10 +82,11 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): department. """ name = models.CharField( - max_length=30, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) group = models.ForeignKey( diff --git a/netbox/virtualization/migrations/0019_standardize_name_length.py b/netbox/virtualization/migrations/0019_standardize_name_length.py new file mode 100644 index 000000000..d6820640d --- /dev/null +++ b/netbox/virtualization/migrations/0019_standardize_name_length.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1 on 2020-10-15 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0018_custom_field_data'), + ] + + operations = [ + migrations.AlterField( + model_name='clustergroup', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='clustergroup', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='clustertype', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='clustertype', + name='slug', + field=models.SlugField(max_length=100, unique=True), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 2c9cd2a9b..198bfd1a5 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -35,10 +35,11 @@ class ClusterType(ChangeLoggedModel): A type of Cluster. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( @@ -76,10 +77,11 @@ class ClusterGroup(ChangeLoggedModel): An organizational group of Clusters. """ name = models.CharField( - max_length=50, + max_length=100, unique=True ) slug = models.SlugField( + max_length=100, unique=True ) description = models.CharField( From 32274dec86a126e54568d953589e90a971b65149 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 15 Oct 2020 20:40:19 -0500 Subject: [PATCH 144/291] Closes: #4967 - Adds Tenancy to Aggregate model --- netbox/ipam/api/serializers.py | 3 +- netbox/ipam/filters.py | 2 +- netbox/ipam/forms.py | 16 +++++-- .../0043_add_tenancy_to_aggregates.py | 20 +++++++++ netbox/ipam/models.py | 12 ++++- netbox/ipam/tables.py | 9 ++-- netbox/ipam/tests/test_filters.py | 45 ++++++++++++++++--- netbox/templates/ipam/aggregate_edit.html | 7 +++ 8 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 netbox/ipam/migrations/0043_add_tenancy_to_aggregates.py diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 31f708c86..7552ae0d2 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -70,11 +70,12 @@ class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) rir = NestedRIRSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = Aggregate fields = [ - 'id', 'url', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created', + 'id', 'url', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 0cbbd3f78..988ee86fb 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -122,7 +122,7 @@ class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'is_private', 'description'] -class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 714279859..ea7014121 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -225,7 +225,7 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # Aggregates # -class AggregateForm(BootstrapMixin, CustomFieldModelForm): +class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all() ) @@ -237,7 +237,7 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Aggregate fields = [ - 'prefix', 'rir', 'date_added', 'description', 'tags', + 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', ] help_texts = { 'prefix': "IPv4 or IPv6 network", @@ -254,6 +254,12 @@ class AggregateCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Assigned RIR' ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) class Meta: model = Aggregate @@ -270,6 +276,10 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd required=False, label='RIR' ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) date_added = forms.DateField( required=False ) @@ -287,7 +297,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd } -class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): +class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Aggregate q = forms.CharField( required=False, diff --git a/netbox/ipam/migrations/0043_add_tenancy_to_aggregates.py b/netbox/ipam/migrations/0043_add_tenancy_to_aggregates.py new file mode 100644 index 000000000..a5ec9013b --- /dev/null +++ b/netbox/ipam/migrations/0043_add_tenancy_to_aggregates.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-10-16 01:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0011_standardize_name_length'), + ('ipam', '0042_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='aggregate', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='tenancy.tenant'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f6414d5d7..493df9d9a 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -222,6 +222,13 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): related_name='aggregates', verbose_name='RIR' ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='aggregates', + blank=True, + null=True + ) date_added = models.DateField( blank=True, null=True @@ -234,9 +241,9 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): objects = RestrictedQuerySet.as_manager() - csv_headers = ['prefix', 'rir', 'date_added', 'description'] + csv_headers = ['prefix', 'rir', 'tenant', 'date_added', 'description'] clone_fields = [ - 'rir', 'date_added', 'description', + 'rir', 'tenant', 'date_added', 'description', ] class Meta: @@ -289,6 +296,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): return ( self.prefix, self.rir.name, + self.tenant.name if self.tenant else None, self.date_added, self.description, ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index c6381a37a..cb1d22a39 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -253,6 +253,9 @@ class AggregateTable(BaseTable): prefix = tables.LinkColumn( verbose_name='Aggregate' ) + tenant = tables.TemplateColumn( + template_code=TENANT_LINK + ) date_added = tables.DateColumn( format="Y-m-d", verbose_name='Added' @@ -260,7 +263,7 @@ class AggregateTable(BaseTable): class Meta(BaseTable.Meta): model = Aggregate - fields = ('pk', 'prefix', 'rir', 'date_added', 'description') + fields = ('pk', 'prefix', 'rir', 'tenant', 'date_added', 'description') class AggregateDetailTable(AggregateTable): @@ -276,8 +279,8 @@ class AggregateDetailTable(AggregateTable): ) class Meta(AggregateTable.Meta): - fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description', 'tags') - default_columns = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description') + fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags') + default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') # diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index aa607eb6b..f86e3f8b6 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -240,13 +240,28 @@ class AggregateTestCase(TestCase): ) RIR.objects.bulk_create(rirs) + tenant_groups = ( + TenantGroup(name='Tenant group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant group 3', slug='tenant-group-3'), + ) + for tenantgroup in tenant_groups: + tenantgroup.save() + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), + ) + Tenant.objects.bulk_create(tenants) + aggregates = ( - Aggregate(prefix='10.1.0.0/16', rir=rirs[0], date_added='2020-01-01'), - Aggregate(prefix='10.2.0.0/16', rir=rirs[0], date_added='2020-01-02'), - Aggregate(prefix='10.3.0.0/16', rir=rirs[1], date_added='2020-01-03'), - Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], date_added='2020-01-04'), - Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], date_added='2020-01-05'), - Aggregate(prefix='2001:db8:3::/48', rir=rirs[2], date_added='2020-01-06'), + Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01'), + Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02'), + Aggregate(prefix='10.3.0.0/16', rir=rirs[1], tenant=tenants[2], date_added='2020-01-03'), + Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], tenant=tenants[0], date_added='2020-01-04'), + Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], tenant=tenants[1], date_added='2020-01-05'), + Aggregate(prefix='2001:db8:3::/48', rir=rirs[2], tenant=tenants[2], date_added='2020-01-06'), ) Aggregate.objects.bulk_create(aggregates) @@ -274,6 +289,24 @@ class AggregateTestCase(TestCase): params = {'rir': [rirs[0].slug, rirs[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + print(self.filterset(params, self.queryset).qs.count()) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + print(self.filterset(params, self.queryset).qs.count()) + + def test_tenant_group(self): + tenant_groups = TenantGroup.objects.all()[:2] + params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} + print(self.filterset(params, self.queryset).qs.count()) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} + print(self.filterset(params, self.queryset).qs.count()) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class RoleTestCase(TestCase): queryset = Role.objects.all() diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html index 3cb83ab54..afd20bb26 100644 --- a/netbox/templates/ipam/aggregate_edit.html +++ b/netbox/templates/ipam/aggregate_edit.html @@ -11,6 +11,13 @@ {% render_field form.description %}
    +
    +
    Tenancy
    +
    + {% render_field form.tenant_group %} + {% render_field form.tenant %} +
    +
    {% if form.custom_fields %}
    Custom Fields
    From 086b1d85c7bd0ae7fb77af7551d22afe27104ef6 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 15 Oct 2020 21:23:32 -0500 Subject: [PATCH 145/291] Update Change Notes --- docs/release-notes/version-2.10.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index e5734ea8e..4be51ca28 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -49,6 +49,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `.` string * [#4918](https://github.com/netbox-community/netbox/issues/4918) - Add a REST API endpoint (`/api/status/`) which returns NetBox's current operational status * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view +* [#4967](https://github.com/netbox-community/netbox/issues/4967) - Adds Tenancy to Aggregate model * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis * [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for content types From 23d56a5758e6ffb42956e6d67b1c36cdd155456a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 10:04:33 -0400 Subject: [PATCH 146/291] Note REST API change for #4967 --- docs/release-notes/version-2.10.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 4be51ca28..5766295af 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -49,7 +49,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `.` string * [#4918](https://github.com/netbox-community/netbox/issues/4918) - Add a REST API endpoint (`/api/status/`) which returns NetBox's current operational status * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view -* [#4967](https://github.com/netbox-community/netbox/issues/4967) - Adds Tenancy to Aggregate model +* [#4967](https://github.com/netbox-community/netbox/issues/4967) - Adds tenancy to Aggregate model * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis * [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for content types @@ -112,6 +112,7 @@ All end-to-end cable paths are now cached using the new CablePath model. This al * extras.Graph: This API endpoint has been removed (see #4349) * extras.ImageAttachment: Filtering by `content_type` now takes a string in the form `.` * extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `.` +* ipam.Aggregate: Added `tenant` field * ipam.RouteTarget: New endpoint * ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers * ipam.VRF: Added `import_targets` and `export_targets` fields From 73bf3b949802a8332c36d1b534a28297b52ea545 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 10:39:13 -0400 Subject: [PATCH 147/291] Reorganize DCIM tables --- netbox/dcim/tables.py | 1018 --------------------------- netbox/dcim/tables/__init__.py | 114 +++ netbox/dcim/tables/cables.py | 64 ++ netbox/dcim/tables/devices.py | 366 ++++++++++ netbox/dcim/tables/devicetypes.py | 192 +++++ netbox/dcim/tables/power.py | 83 +++ netbox/dcim/tables/racks.py | 167 +++++ netbox/dcim/tables/sites.py | 62 ++ netbox/dcim/tables/template_code.py | 65 ++ netbox/virtualization/tables.py | 2 +- 10 files changed, 1114 insertions(+), 1019 deletions(-) delete mode 100644 netbox/dcim/tables.py create mode 100644 netbox/dcim/tables/__init__.py create mode 100644 netbox/dcim/tables/cables.py create mode 100644 netbox/dcim/tables/devices.py create mode 100644 netbox/dcim/tables/devicetypes.py create mode 100644 netbox/dcim/tables/power.py create mode 100644 netbox/dcim/tables/racks.py create mode 100644 netbox/dcim/tables/sites.py create mode 100644 netbox/dcim/tables/template_code.py diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py deleted file mode 100644 index 437daaf29..000000000 --- a/netbox/dcim/tables.py +++ /dev/null @@ -1,1018 +0,0 @@ -import django_tables2 as tables -from django_tables2.utils import Accessor - -from tenancy.tables import COL_TENANT -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, - TagColumn, ToggleColumn, -) -from .models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) - -MPTT_LINK = """ -{% if record.get_children %} - -{% else %} - -{% endif %} - {{ record.name }} - -""" - -DEVICE_LINK = """ - - {{ record.name|default:'Unnamed device' }} - -""" - -RACKGROUP_ELEVATIONS = """ - - - -""" - -UTILIZATION_GRAPH = """ -{% load helpers %} -{% utilization_graph value %} -""" - -CABLE_TERMINATION_PARENT = """ -{% if value.device %} - {{ value.device }} -{% elif value.circuit %} - {{ value.circuit }} -{% elif value.power_panel %} - {{ value.power_panel }} -{% endif %} -""" - -CABLE_LENGTH = """ -{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} -""" - -INTERFACE_IPADDRESSES = """ -{% for ip in record.ip_addresses.unrestricted %} - {{ ip }}
    -{% endfor %} -""" - -INTERFACE_TAGGED_VLANS = """ -{% for vlan in record.tagged_vlans.unrestricted %} - {{ vlan }}
    -{% endfor %} -""" - -POWERFEED_CABLE = """ -{{ value }} - - - -""" - -POWERFEED_CABLETERMINATION = """ -{{ value.parent }} - -{{ value }} -""" - - -# -# Regions -# - -class RegionTable(BaseTable): - pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False - ) - site_count = tables.Column( - verbose_name='Sites' - ) - actions = ButtonsColumn(Region) - - class Meta(BaseTable.Meta): - model = Region - fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') - - -# -# Sites -# - -class SiteTable(BaseTable): - pk = ToggleColumn() - name = tables.LinkColumn( - order_by=('_name',) - ) - status = ChoiceFieldColumn() - region = tables.Column( - linkify=True - ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) - tags = TagColumn( - url_name='dcim:site_list' - ) - - class Meta(BaseTable.Meta): - model = Site - fields = ( - 'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'tags', - ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description') - - -# -# Rack groups -# - -class RackGroupTable(BaseTable): - pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False - ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')], - verbose_name='Site' - ) - rack_count = tables.Column( - verbose_name='Racks' - ) - actions = ButtonsColumn( - model=RackGroup, - prepend_template=RACKGROUP_ELEVATIONS - ) - - class Meta(BaseTable.Meta): - model = RackGroup - fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions') - - -# -# Rack roles -# - -class RackRoleTable(BaseTable): - pk = ToggleColumn() - name = tables.Column(linkify=True) - rack_count = tables.Column(verbose_name='Racks') - color = ColorColumn() - actions = ButtonsColumn(RackRole) - - class Meta(BaseTable.Meta): - model = RackRole - fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') - - -# -# Racks -# - -class RackTable(BaseTable): - pk = ToggleColumn() - name = tables.LinkColumn( - order_by=('_name',) - ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] - ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) - status = ChoiceFieldColumn() - role = ColoredLabelColumn() - u_height = tables.TemplateColumn( - template_code="{{ record.u_height }}U", - verbose_name='Height' - ) - - class Meta(BaseTable.Meta): - model = Rack - fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', - 'width', 'u_height', - ) - default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height') - - -class RackDetailTable(RackTable): - device_count = LinkedCountColumn( - viewname='dcim:device_list', - url_params={'rack_id': 'pk'}, - verbose_name='Devices' - ) - get_utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, - orderable=False, - verbose_name='Space' - ) - get_power_utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, - orderable=False, - verbose_name='Power' - ) - tags = TagColumn( - url_name='dcim:rack_list' - ) - - class Meta(RackTable.Meta): - fields = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', - 'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', - ) - default_columns = ( - 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', - 'get_utilization', 'get_power_utilization', - ) - - -# -# Rack reservations -# - -class RackReservationTable(BaseTable): - pk = ToggleColumn() - reservation = tables.Column( - accessor='pk', - linkify=True - ) - site = tables.Column( - accessor=Accessor('rack__site'), - linkify=True - ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) - rack = tables.Column( - linkify=True - ) - unit_list = tables.Column( - orderable=False, - verbose_name='Units' - ) - tags = TagColumn( - url_name='dcim:rackreservation_list' - ) - actions = ButtonsColumn(RackReservation) - - class Meta(BaseTable.Meta): - model = RackReservation - fields = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', - 'actions', - ) - default_columns = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', - ) - - -# -# Manufacturers -# - -class ManufacturerTable(BaseTable): - pk = ToggleColumn() - name = tables.LinkColumn() - devicetype_count = tables.Column( - verbose_name='Device Types' - ) - inventoryitem_count = tables.Column( - verbose_name='Inventory Items' - ) - platform_count = tables.Column( - verbose_name='Platforms' - ) - slug = tables.Column() - actions = ButtonsColumn(Manufacturer, pk_field='slug') - - class Meta(BaseTable.Meta): - model = Manufacturer - fields = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', - ) - - -# -# Device types -# - -class DeviceTypeTable(BaseTable): - pk = ToggleColumn() - model = tables.Column( - linkify=True, - verbose_name='Device Type' - ) - is_full_depth = BooleanColumn( - verbose_name='Full Depth' - ) - instance_count = LinkedCountColumn( - viewname='dcim:device_list', - url_params={'device_type_id': 'pk'}, - verbose_name='Instances' - ) - tags = TagColumn( - url_name='dcim:devicetype_list' - ) - - class Meta(BaseTable.Meta): - model = DeviceType - fields = ( - 'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'instance_count', 'tags', - ) - default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', - ) - - -# -# Device type components -# - -class ComponentTemplateTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - order_by=('_name',) - ) - - -class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsolePortTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = ConsolePortTemplate - fields = ('pk', 'name', 'label', 'type', 'description', 'actions') - empty_text = "None" - - -class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsoleServerPortTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = ConsoleServerPortTemplate - fields = ('pk', 'name', 'label', 'type', 'description', 'actions') - empty_text = "None" - - -class PowerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerPortTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = PowerPortTemplate - fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') - empty_text = "None" - - -class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerOutletTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = PowerOutletTemplate - fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') - empty_text = "None" - - -class InterfaceTemplateTable(ComponentTemplateTable): - mgmt_only = BooleanColumn( - verbose_name='Management Only' - ) - actions = ButtonsColumn( - model=InterfaceTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = InterfaceTemplate - fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions') - empty_text = "None" - - -class FrontPortTemplateTable(ComponentTemplateTable): - rear_port_position = tables.Column( - verbose_name='Position' - ) - actions = ButtonsColumn( - model=FrontPortTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = FrontPortTemplate - fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions') - empty_text = "None" - - -class RearPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=RearPortTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = RearPortTemplate - fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions') - empty_text = "None" - - -class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=DeviceBayTemplate, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = DeviceBayTemplate - fields = ('pk', 'name', 'label', 'description', 'actions') - empty_text = "None" - - -# -# Device roles -# - -class DeviceRoleTable(BaseTable): - pk = ToggleColumn() - device_count = LinkedCountColumn( - viewname='dcim:device_list', - url_params={'role': 'slug'}, - verbose_name='Devices' - ) - vm_count = LinkedCountColumn( - viewname='virtualization:virtualmachine_list', - url_params={'role': 'slug'}, - verbose_name='VMs' - ) - color = ColorColumn() - vm_role = BooleanColumn() - actions = ButtonsColumn(DeviceRole, pk_field='slug') - - class Meta(BaseTable.Meta): - model = DeviceRole - fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') - default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') - - -# -# Platforms -# - -class PlatformTable(BaseTable): - pk = ToggleColumn() - device_count = LinkedCountColumn( - viewname='dcim:device_list', - url_params={'platform': 'slug'}, - verbose_name='Devices' - ) - vm_count = LinkedCountColumn( - viewname='virtualization:virtualmachine_list', - url_params={'platform': 'slug'}, - verbose_name='VMs' - ) - actions = ButtonsColumn(Platform, pk_field='slug') - - class Meta(BaseTable.Meta): - model = Platform - fields = ( - 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'actions', - ) - default_columns = ( - 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', - ) - - -# -# Devices -# - -class DeviceTable(BaseTable): - pk = ToggleColumn() - name = tables.TemplateColumn( - order_by=('_name',), - template_code=DEVICE_LINK - ) - status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) - site = tables.Column( - linkify=True - ) - rack = tables.Column( - linkify=True - ) - device_role = ColoredLabelColumn( - verbose_name='Role' - ) - device_type = tables.LinkColumn( - viewname='dcim:devicetype', - args=[Accessor('device_type__pk')], - verbose_name='Type', - text=lambda record: record.device_type.display_name - ) - primary_ip = tables.Column( - linkify=True, - verbose_name='IP Address' - ) - primary_ip4 = tables.Column( - linkify=True, - verbose_name='IPv4 Address' - ) - primary_ip6 = tables.Column( - linkify=True, - verbose_name='IPv6 Address' - ) - cluster = tables.LinkColumn( - viewname='virtualization:cluster', - args=[Accessor('cluster__pk')] - ) - virtual_chassis = tables.LinkColumn( - viewname='dcim:virtualchassis', - args=[Accessor('virtual_chassis__pk')] - ) - vc_position = tables.Column( - verbose_name='VC Position' - ) - vc_priority = tables.Column( - verbose_name='VC Priority' - ) - tags = TagColumn( - url_name='dcim:device_list' - ) - - class Meta(BaseTable.Meta): - model = Device - fields = ( - 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', - 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', - 'vc_position', 'vc_priority', 'tags', - ) - default_columns = ( - 'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip', - ) - - -class DeviceImportTable(BaseTable): - name = tables.TemplateColumn( - template_code=DEVICE_LINK - ) - status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) - site = tables.Column( - linkify=True - ) - rack = tables.Column( - linkify=True - ) - device_role = tables.Column( - verbose_name='Role' - ) - device_type = tables.Column( - verbose_name='Type' - ) - - class Meta(BaseTable.Meta): - model = Device - fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') - empty_text = False - - -# -# Device components -# - -class DeviceComponentTable(BaseTable): - pk = ToggleColumn() - device = tables.Column( - linkify=True - ) - name = tables.Column( - linkify=True, - order_by=('_name',) - ) - cable = tables.Column( - linkify=True - ) - - class Meta(BaseTable.Meta): - order_by = ('device', 'name') - - -class ConsolePortTable(DeviceComponentTable): - tags = TagColumn( - url_name='dcim:consoleport_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = ConsolePort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') - - -class ConsoleServerPortTable(DeviceComponentTable): - tags = TagColumn( - url_name='dcim:consoleserverport_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = ConsoleServerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') - - -class PowerPortTable(DeviceComponentTable): - tags = TagColumn( - url_name='dcim:powerport_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = PowerPort - fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags', - ) - default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') - - -class PowerOutletTable(DeviceComponentTable): - tags = TagColumn( - url_name='dcim:poweroutlet_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = PowerOutlet - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') - - -class BaseInterfaceTable(BaseTable): - enabled = BooleanColumn() - ip_addresses = tables.TemplateColumn( - template_code=INTERFACE_IPADDRESSES, - orderable=False, - verbose_name='IP Addresses' - ) - untagged_vlan = tables.Column(linkify=True) - tagged_vlans = tables.TemplateColumn( - template_code=INTERFACE_TAGGED_VLANS, - orderable=False, - verbose_name='Tagged VLANs' - ) - - -class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): - tags = TagColumn( - url_name='dcim:interface_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = Interface - fields = ( - 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', - ) - default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') - - -class FrontPortTable(DeviceComponentTable): - rear_port_position = tables.Column( - verbose_name='Position' - ) - tags = TagColumn( - url_name='dcim:frontport_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = FrontPort - fields = ( - 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags', - ) - default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') - - -class RearPortTable(DeviceComponentTable): - tags = TagColumn( - url_name='dcim:rearport_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = RearPort - fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') - - -class DeviceBayTable(DeviceComponentTable): - installed_device = tables.Column( - linkify=True - ) - tags = TagColumn( - url_name='dcim:devicebay_list' - ) - - class Meta(DeviceComponentTable.Meta): - model = DeviceBay - fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') - - -class InventoryItemTable(DeviceComponentTable): - manufacturer = tables.Column( - linkify=True - ) - discovered = BooleanColumn() - tags = TagColumn( - url_name='dcim:inventoryitem_list' - ) - cable = None # Override DeviceComponentTable - - class Meta(DeviceComponentTable.Meta): - model = InventoryItem - fields = ( - 'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'discovered', 'tags', - ) - default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') - - -# -# Cables -# - -class CableTable(BaseTable): - pk = ToggleColumn() - id = tables.Column( - linkify=True, - verbose_name='ID' - ) - termination_a_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_a'), - orderable=False, - verbose_name='Side A' - ) - termination_a = tables.LinkColumn( - accessor=Accessor('termination_a'), - orderable=False, - verbose_name='Termination A' - ) - termination_b_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_b'), - orderable=False, - verbose_name='Side B' - ) - termination_b = tables.LinkColumn( - accessor=Accessor('termination_b'), - orderable=False, - verbose_name='Termination B' - ) - status = ChoiceFieldColumn() - length = tables.TemplateColumn( - template_code=CABLE_LENGTH, - order_by='_abs_length' - ) - color = ColorColumn() - tags = TagColumn( - url_name='dcim:cable_list' - ) - - class Meta(BaseTable.Meta): - model = Cable - fields = ( - 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', 'color', 'length', 'tags', - ) - default_columns = ( - 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', - ) - - -# -# Device connections -# - -class ConsoleConnectionTable(BaseTable): - console_server = tables.Column( - accessor=Accessor('_path__destination__device'), - orderable=False, - linkify=True, - verbose_name='Console Server' - ) - console_server_port = tables.Column( - accessor=Accessor('_path__destination'), - orderable=False, - linkify=True, - verbose_name='Port' - ) - device = tables.Column( - linkify=True - ) - name = tables.Column( - linkify=True, - verbose_name='Console Port' - ) - reachable = BooleanColumn( - accessor=Accessor('_path__is_active'), - verbose_name='Reachable' - ) - - add_prefetch = False - - class Meta(BaseTable.Meta): - model = ConsolePort - fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') - - -class PowerConnectionTable(BaseTable): - pdu = tables.Column( - accessor=Accessor('_path__destination__device'), - orderable=False, - linkify=True, - verbose_name='PDU' - ) - outlet = tables.Column( - accessor=Accessor('_path__destination'), - orderable=False, - linkify=True, - verbose_name='Outlet' - ) - device = tables.Column( - linkify=True - ) - name = tables.Column( - linkify=True, - verbose_name='Power Port' - ) - reachable = BooleanColumn( - accessor=Accessor('_path__is_active'), - verbose_name='Reachable' - ) - - add_prefetch = False - - class Meta(BaseTable.Meta): - model = PowerPort - fields = ('device', 'name', 'pdu', 'outlet', 'reachable') - - -class InterfaceConnectionTable(BaseTable): - device_a = tables.Column( - accessor=Accessor('device'), - linkify=True, - verbose_name='Device A' - ) - interface_a = tables.Column( - accessor=Accessor('name'), - linkify=True, - verbose_name='Interface A' - ) - device_b = tables.Column( - accessor=Accessor('_path__destination__device'), - orderable=False, - linkify=True, - verbose_name='Device B' - ) - interface_b = tables.Column( - accessor=Accessor('_path__destination'), - orderable=False, - linkify=True, - verbose_name='Interface B' - ) - reachable = BooleanColumn( - accessor=Accessor('_path__is_active'), - verbose_name='Reachable' - ) - - add_prefetch = False - - class Meta(BaseTable.Meta): - model = Interface - fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') - - -# -# Virtual chassis -# - -class VirtualChassisTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - master = tables.Column( - linkify=True - ) - member_count = LinkedCountColumn( - viewname='dcim:device_list', - url_params={'virtual_chassis_id': 'pk'}, - verbose_name='Members' - ) - tags = TagColumn( - url_name='dcim:virtualchassis_list' - ) - - class Meta(BaseTable.Meta): - model = VirtualChassis - fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags') - default_columns = ('pk', 'name', 'domain', 'master', 'member_count') - - -# -# Power panels -# - -class PowerPanelTable(BaseTable): - pk = ToggleColumn() - name = tables.LinkColumn() - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] - ) - powerfeed_count = LinkedCountColumn( - viewname='dcim:powerfeed_list', - url_params={'power_panel_id': 'pk'}, - verbose_name='Feeds' - ) - tags = TagColumn( - url_name='dcim:powerpanel_list' - ) - - class Meta(BaseTable.Meta): - model = PowerPanel - fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags') - default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') - - -# -# Power feeds -# - -class PowerFeedTable(BaseTable): - pk = ToggleColumn() - name = tables.LinkColumn() - power_panel = tables.Column( - linkify=True - ) - rack = tables.Column( - linkify=True - ) - status = ChoiceFieldColumn() - type = ChoiceFieldColumn() - max_utilization = tables.TemplateColumn( - template_code="{{ value }}%" - ) - cable = tables.TemplateColumn( - template_code=POWERFEED_CABLE, - orderable=False - ) - connection = tables.TemplateColumn( - accessor='get_cable_peer', - template_code=POWERFEED_CABLETERMINATION, - orderable=False - ) - available_power = tables.Column( - verbose_name='Available power (VA)' - ) - tags = TagColumn( - url_name='dcim:powerfeed_list' - ) - - class Meta(BaseTable.Meta): - model = PowerFeed - fields = ( - 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'cable', 'connection', 'available_power', 'tags', - ) - default_columns = ( - 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', - 'connection', - ) diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py new file mode 100644 index 000000000..e68779e3d --- /dev/null +++ b/netbox/dcim/tables/__init__.py @@ -0,0 +1,114 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from utilities.tables import BaseTable, BooleanColumn +from dcim.models import ConsolePort, Interface, PowerPort +from .cables import * +from .devices import * +from .devicetypes import * +from .power import * +from .racks import * +from .sites import * + + +# +# Device connections +# + +class ConsoleConnectionTable(BaseTable): + console_server = tables.Column( + accessor=Accessor('_path__destination__device'), + orderable=False, + linkify=True, + verbose_name='Console Server' + ) + console_server_port = tables.Column( + accessor=Accessor('_path__destination'), + orderable=False, + linkify=True, + verbose_name='Port' + ) + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + verbose_name='Console Port' + ) + reachable = BooleanColumn( + accessor=Accessor('_path__is_active'), + verbose_name='Reachable' + ) + + add_prefetch = False + + class Meta(BaseTable.Meta): + model = ConsolePort + fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') + + +class PowerConnectionTable(BaseTable): + pdu = tables.Column( + accessor=Accessor('_path__destination__device'), + orderable=False, + linkify=True, + verbose_name='PDU' + ) + outlet = tables.Column( + accessor=Accessor('_path__destination'), + orderable=False, + linkify=True, + verbose_name='Outlet' + ) + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + verbose_name='Power Port' + ) + reachable = BooleanColumn( + accessor=Accessor('_path__is_active'), + verbose_name='Reachable' + ) + + add_prefetch = False + + class Meta(BaseTable.Meta): + model = PowerPort + fields = ('device', 'name', 'pdu', 'outlet', 'reachable') + + +class InterfaceConnectionTable(BaseTable): + device_a = tables.Column( + accessor=Accessor('device'), + linkify=True, + verbose_name='Device A' + ) + interface_a = tables.Column( + accessor=Accessor('name'), + linkify=True, + verbose_name='Interface A' + ) + device_b = tables.Column( + accessor=Accessor('_path__destination__device'), + orderable=False, + linkify=True, + verbose_name='Device B' + ) + interface_b = tables.Column( + accessor=Accessor('_path__destination'), + orderable=False, + linkify=True, + verbose_name='Interface B' + ) + reachable = BooleanColumn( + accessor=Accessor('_path__is_active'), + verbose_name='Reachable' + ) + + add_prefetch = False + + class Meta(BaseTable.Meta): + model = Interface + fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py new file mode 100644 index 000000000..cdb79f4e1 --- /dev/null +++ b/netbox/dcim/tables/cables.py @@ -0,0 +1,64 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from dcim.models import Cable +from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn +from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT + +__all__ = ( + 'CableTable', +) + + +# +# Cables +# + +class CableTable(BaseTable): + pk = ToggleColumn() + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + termination_a_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='Side A' + ) + termination_a = tables.LinkColumn( + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='Termination A' + ) + termination_b_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='Side B' + ) + termination_b = tables.LinkColumn( + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='Termination B' + ) + status = ChoiceFieldColumn() + length = tables.TemplateColumn( + template_code=CABLE_LENGTH, + order_by='_abs_length' + ) + color = ColorColumn() + tags = TagColumn( + url_name='dcim:cable_list' + ) + + class Meta(BaseTable.Meta): + model = Cable + fields = ( + 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', + 'status', 'type', 'color', 'length', 'tags', + ) + default_columns = ( + 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', + 'status', 'type', + ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py new file mode 100644 index 000000000..c614c534b --- /dev/null +++ b/netbox/dcim/tables/devices.py @@ -0,0 +1,366 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from dcim.models import ( + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, + PowerOutlet, PowerPort, RearPort, VirtualChassis, +) +from tenancy.tables import COL_TENANT +from utilities.tables import ( + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, + TagColumn, ToggleColumn, +) +from .template_code import DEVICE_LINK, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS + +__all__ = ( + 'ConsolePortTable', + 'ConsoleServerPortTable', + 'DeviceImportTable', + 'DeviceTable', + 'DeviceBayTable', + 'DeviceRoleTable', + 'FrontPortTable', + 'InterfaceTable', + 'InventoryItemTable', + 'PlatformTable', + 'PowerOutletTable', + 'PowerPortTable', + 'RearPortTable', + 'VirtualChassisTable', +) + + +# +# Device roles +# + +class DeviceRoleTable(BaseTable): + pk = ToggleColumn() + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'role': 'slug'}, + verbose_name='Devices' + ) + vm_count = LinkedCountColumn( + viewname='virtualization:virtualmachine_list', + url_params={'role': 'slug'}, + verbose_name='VMs' + ) + color = ColorColumn() + vm_role = BooleanColumn() + actions = ButtonsColumn(DeviceRole, pk_field='slug') + + class Meta(BaseTable.Meta): + model = DeviceRole + fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') + + +# +# Platforms +# + +class PlatformTable(BaseTable): + pk = ToggleColumn() + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'platform': 'slug'}, + verbose_name='Devices' + ) + vm_count = LinkedCountColumn( + viewname='virtualization:virtualmachine_list', + url_params={'platform': 'slug'}, + verbose_name='VMs' + ) + actions = ButtonsColumn(Platform, pk_field='slug') + + class Meta(BaseTable.Meta): + model = Platform + fields = ( + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', + 'description', 'actions', + ) + default_columns = ( + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', + ) + + +# +# Devices +# + +class DeviceTable(BaseTable): + pk = ToggleColumn() + name = tables.TemplateColumn( + order_by=('_name',), + template_code=DEVICE_LINK + ) + status = ChoiceFieldColumn() + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + site = tables.Column( + linkify=True + ) + rack = tables.Column( + linkify=True + ) + device_role = ColoredLabelColumn( + verbose_name='Role' + ) + device_type = tables.LinkColumn( + viewname='dcim:devicetype', + args=[Accessor('device_type__pk')], + verbose_name='Type', + text=lambda record: record.device_type.display_name + ) + primary_ip = tables.Column( + linkify=True, + verbose_name='IP Address' + ) + primary_ip4 = tables.Column( + linkify=True, + verbose_name='IPv4 Address' + ) + primary_ip6 = tables.Column( + linkify=True, + verbose_name='IPv6 Address' + ) + cluster = tables.LinkColumn( + viewname='virtualization:cluster', + args=[Accessor('cluster__pk')] + ) + virtual_chassis = tables.LinkColumn( + viewname='dcim:virtualchassis', + args=[Accessor('virtual_chassis__pk')] + ) + vc_position = tables.Column( + verbose_name='VC Position' + ) + vc_priority = tables.Column( + verbose_name='VC Priority' + ) + tags = TagColumn( + url_name='dcim:device_list' + ) + + class Meta(BaseTable.Meta): + model = Device + fields = ( + 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', + 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', + 'vc_position', 'vc_priority', 'tags', + ) + default_columns = ( + 'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip', + ) + + +class DeviceImportTable(BaseTable): + name = tables.TemplateColumn( + template_code=DEVICE_LINK + ) + status = ChoiceFieldColumn() + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + site = tables.Column( + linkify=True + ) + rack = tables.Column( + linkify=True + ) + device_role = tables.Column( + verbose_name='Role' + ) + device_type = tables.Column( + verbose_name='Type' + ) + + class Meta(BaseTable.Meta): + model = Device + fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') + empty_text = False + + +# +# Device components +# + +class DeviceComponentTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + order_by=('_name',) + ) + cable = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + order_by = ('device', 'name') + + +class ConsolePortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ConsolePort + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + + +class ConsoleServerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:consoleserverport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ConsoleServerPort + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + + +class PowerPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:powerport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = PowerPort + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') + + +class PowerOutletTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:poweroutlet_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = PowerOutlet + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') + + +class BaseInterfaceTable(BaseTable): + enabled = BooleanColumn() + ip_addresses = tables.TemplateColumn( + template_code=INTERFACE_IPADDRESSES, + orderable=False, + verbose_name='IP Addresses' + ) + untagged_vlan = tables.Column(linkify=True) + tagged_vlans = tables.TemplateColumn( + template_code=INTERFACE_TAGGED_VLANS, + orderable=False, + verbose_name='Tagged VLANs' + ) + + +class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): + tags = TagColumn( + url_name='dcim:interface_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = Interface + fields = ( + 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', + 'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + ) + default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') + + +class FrontPortTable(DeviceComponentTable): + rear_port_position = tables.Column( + verbose_name='Position' + ) + tags = TagColumn( + url_name='dcim:frontport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = FrontPort + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') + + +class RearPortTable(DeviceComponentTable): + tags = TagColumn( + url_name='dcim:rearport_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = RearPort + fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + + +class DeviceBayTable(DeviceComponentTable): + installed_device = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='dcim:devicebay_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = DeviceBay + fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') + + +class InventoryItemTable(DeviceComponentTable): + manufacturer = tables.Column( + linkify=True + ) + discovered = BooleanColumn() + tags = TagColumn( + url_name='dcim:inventoryitem_list' + ) + cable = None # Override DeviceComponentTable + + class Meta(DeviceComponentTable.Meta): + model = InventoryItem + fields = ( + 'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'discovered', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') + + +# +# Virtual chassis +# + +class VirtualChassisTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + master = tables.Column( + linkify=True + ) + member_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'virtual_chassis_id': 'pk'}, + verbose_name='Members' + ) + tags = TagColumn( + url_name='dcim:virtualchassis_list' + ) + + class Meta(BaseTable.Meta): + model = VirtualChassis + fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags') + default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py new file mode 100644 index 000000000..b65289fcf --- /dev/null +++ b/netbox/dcim/tables/devicetypes.py @@ -0,0 +1,192 @@ +import django_tables2 as tables + +from dcim.models import ( + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, + Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, +) +from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn + +__all__ = ( + 'ConsolePortTemplateTable', + 'ConsoleServerPortTemplateTable', + 'DeviceBayTemplateTable', + 'DeviceTypeTable', + 'FrontPortTemplateTable', + 'InterfaceTemplateTable', + 'ManufacturerTable', + 'PowerOutletTemplateTable', + 'PowerPortTemplateTable', + 'RearPortTemplateTable', +) + + +# +# Manufacturers +# + +class ManufacturerTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + devicetype_count = tables.Column( + verbose_name='Device Types' + ) + inventoryitem_count = tables.Column( + verbose_name='Inventory Items' + ) + platform_count = tables.Column( + verbose_name='Platforms' + ) + slug = tables.Column() + actions = ButtonsColumn(Manufacturer, pk_field='slug') + + class Meta(BaseTable.Meta): + model = Manufacturer + fields = ( + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + ) + + +# +# Device types +# + +class DeviceTypeTable(BaseTable): + pk = ToggleColumn() + model = tables.Column( + linkify=True, + verbose_name='Device Type' + ) + is_full_depth = BooleanColumn( + verbose_name='Full Depth' + ) + instance_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'device_type_id': 'pk'}, + verbose_name='Instances' + ) + tags = TagColumn( + url_name='dcim:devicetype_list' + ) + + class Meta(BaseTable.Meta): + model = DeviceType + fields = ( + 'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'instance_count', 'tags', + ) + default_columns = ( + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', + ) + + +# +# Device type components +# + +class ComponentTemplateTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + order_by=('_name',) + ) + + +class ConsolePortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=ConsolePortTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = ConsolePortTemplate + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') + empty_text = "None" + + +class ConsoleServerPortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=ConsoleServerPortTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = ConsoleServerPortTemplate + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') + empty_text = "None" + + +class PowerPortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=PowerPortTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = PowerPortTemplate + fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') + empty_text = "None" + + +class PowerOutletTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=PowerOutletTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = PowerOutletTemplate + fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') + empty_text = "None" + + +class InterfaceTemplateTable(ComponentTemplateTable): + mgmt_only = BooleanColumn( + verbose_name='Management Only' + ) + actions = ButtonsColumn( + model=InterfaceTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = InterfaceTemplate + fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions') + empty_text = "None" + + +class FrontPortTemplateTable(ComponentTemplateTable): + rear_port_position = tables.Column( + verbose_name='Position' + ) + actions = ButtonsColumn( + model=FrontPortTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = FrontPortTemplate + fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions') + empty_text = "None" + + +class RearPortTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=RearPortTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = RearPortTemplate + fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions') + empty_text = "None" + + +class DeviceBayTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=DeviceBayTemplate, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = DeviceBayTemplate + fields = ('pk', 'name', 'label', 'description', 'actions') + empty_text = "None" diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py new file mode 100644 index 000000000..8d90fb620 --- /dev/null +++ b/netbox/dcim/tables/power.py @@ -0,0 +1,83 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from dcim.models import PowerFeed, PowerPanel +from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn +from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION + +__all__ = ( + 'PowerFeedTable', + 'PowerPanelTable', +) + + +# +# Power panels +# + +class PowerPanelTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site__slug')] + ) + powerfeed_count = LinkedCountColumn( + viewname='dcim:powerfeed_list', + url_params={'power_panel_id': 'pk'}, + verbose_name='Feeds' + ) + tags = TagColumn( + url_name='dcim:powerpanel_list' + ) + + class Meta(BaseTable.Meta): + model = PowerPanel + fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags') + default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') + + +# +# Power feeds +# + +class PowerFeedTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + power_panel = tables.Column( + linkify=True + ) + rack = tables.Column( + linkify=True + ) + status = ChoiceFieldColumn() + type = ChoiceFieldColumn() + max_utilization = tables.TemplateColumn( + template_code="{{ value }}%" + ) + cable = tables.TemplateColumn( + template_code=POWERFEED_CABLE, + orderable=False + ) + connection = tables.TemplateColumn( + accessor='get_cable_peer', + template_code=POWERFEED_CABLETERMINATION, + orderable=False + ) + available_power = tables.Column( + verbose_name='Available power (VA)' + ) + tags = TagColumn( + url_name='dcim:powerfeed_list' + ) + + class Meta(BaseTable.Meta): + model = PowerFeed + fields = ( + 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', + 'max_utilization', 'cable', 'connection', 'available_power', 'tags', + ) + default_columns = ( + 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', + 'connection', + ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py new file mode 100644 index 000000000..75a5c85ea --- /dev/null +++ b/netbox/dcim/tables/racks.py @@ -0,0 +1,167 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from dcim.models import Rack, RackGroup, RackReservation, RackRole +from tenancy.tables import COL_TENANT +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, + ToggleColumn, +) +from .template_code import MPTT_LINK, RACKGROUP_ELEVATIONS, UTILIZATION_GRAPH + +__all__ = ( + 'RackTable', + 'RackDetailTable', + 'RackGroupTable', + 'RackReservationTable', + 'RackRoleTable', +) + + +# +# Rack groups +# + +class RackGroupTable(BaseTable): + pk = ToggleColumn() + name = tables.TemplateColumn( + template_code=MPTT_LINK, + orderable=False + ) + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site__slug')], + verbose_name='Site' + ) + rack_count = tables.Column( + verbose_name='Racks' + ) + actions = ButtonsColumn( + model=RackGroup, + prepend_template=RACKGROUP_ELEVATIONS + ) + + class Meta(BaseTable.Meta): + model = RackGroup + fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions') + + +# +# Rack roles +# + +class RackRoleTable(BaseTable): + pk = ToggleColumn() + name = tables.Column(linkify=True) + rack_count = tables.Column(verbose_name='Racks') + color = ColorColumn() + actions = ButtonsColumn(RackRole) + + class Meta(BaseTable.Meta): + model = RackRole + fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') + + +# +# Racks +# + +class RackTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn( + order_by=('_name',) + ) + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site__slug')] + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + status = ChoiceFieldColumn() + role = ColoredLabelColumn() + u_height = tables.TemplateColumn( + template_code="{{ record.u_height }}U", + verbose_name='Height' + ) + + class Meta(BaseTable.Meta): + model = Rack + fields = ( + 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'width', 'u_height', + ) + default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height') + + +class RackDetailTable(RackTable): + device_count = LinkedCountColumn( + viewname='dcim:device_list', + url_params={'rack_id': 'pk'}, + verbose_name='Devices' + ) + get_utilization = tables.TemplateColumn( + template_code=UTILIZATION_GRAPH, + orderable=False, + verbose_name='Space' + ) + get_power_utilization = tables.TemplateColumn( + template_code=UTILIZATION_GRAPH, + orderable=False, + verbose_name='Power' + ) + tags = TagColumn( + url_name='dcim:rack_list' + ) + + class Meta(RackTable.Meta): + fields = ( + 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', + 'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', + ) + default_columns = ( + 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', + 'get_utilization', 'get_power_utilization', + ) + + +# +# Rack reservations +# + +class RackReservationTable(BaseTable): + pk = ToggleColumn() + reservation = tables.Column( + accessor='pk', + linkify=True + ) + site = tables.Column( + accessor=Accessor('rack__site'), + linkify=True + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + rack = tables.Column( + linkify=True + ) + unit_list = tables.Column( + orderable=False, + verbose_name='Units' + ) + tags = TagColumn( + url_name='dcim:rackreservation_list' + ) + actions = ButtonsColumn(RackReservation) + + class Meta(BaseTable.Meta): + model = RackReservation + fields = ( + 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', + 'actions', + ) + default_columns = ( + 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', + ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py new file mode 100644 index 000000000..76f30f507 --- /dev/null +++ b/netbox/dcim/tables/sites.py @@ -0,0 +1,62 @@ +import django_tables2 as tables + +from dcim.models import Region, Site +from tenancy.tables import COL_TENANT +from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn +from .template_code import MPTT_LINK + +__all__ = ( + 'RegionTable', + 'SiteTable', +) + + +# +# Regions +# + +class RegionTable(BaseTable): + pk = ToggleColumn() + name = tables.TemplateColumn( + template_code=MPTT_LINK, + orderable=False + ) + site_count = tables.Column( + verbose_name='Sites' + ) + actions = ButtonsColumn(Region) + + class Meta(BaseTable.Meta): + model = Region + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + + +# +# Sites +# + +class SiteTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn( + order_by=('_name',) + ) + status = ChoiceFieldColumn() + region = tables.Column( + linkify=True + ) + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + tags = TagColumn( + url_name='dcim:site_list' + ) + + class Meta(BaseTable.Meta): + model = Site + fields = ( + 'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'tags', + ) + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py new file mode 100644 index 000000000..b3840b48b --- /dev/null +++ b/netbox/dcim/tables/template_code.py @@ -0,0 +1,65 @@ +CABLE_LENGTH = """ +{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} +""" + +CABLE_TERMINATION_PARENT = """ +{% if value.device %} + {{ value.device }} +{% elif value.circuit %} + {{ value.circuit }} +{% elif value.power_panel %} + {{ value.power_panel }} +{% endif %} +""" + +DEVICE_LINK = """ + + {{ record.name|default:'Unnamed device' }} + +""" + +INTERFACE_IPADDRESSES = """ +{% for ip in record.ip_addresses.unrestricted %} + {{ ip }}
    +{% endfor %} +""" + +INTERFACE_TAGGED_VLANS = """ +{% for vlan in record.tagged_vlans.unrestricted %} + {{ vlan }}
    +{% endfor %} +""" + +MPTT_LINK = """ +{% if record.get_children %} + +{% else %} + +{% endif %} + {{ record.name }} + +""" + +POWERFEED_CABLE = """ +{{ value }} + + + +""" + +POWERFEED_CABLETERMINATION = """ +{{ value.parent }} + +{{ value }} +""" + +RACKGROUP_ELEVATIONS = """ + + + +""" + +UTILIZATION_GRAPH = """ +{% load helpers %} +{% utilization_graph value %} +""" diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 7acae4cc0..956c20784 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from dcim.tables import BaseInterfaceTable +from dcim.tables.devices import BaseInterfaceTable from tenancy.tables import COL_TENANT from utilities.tables import ( BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, From 823aa6b7127f8170cf4091862ca30d4fe90f6726 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 11:05:42 -0400 Subject: [PATCH 148/291] Add compatible types for PowerFeed --- netbox/dcim/constants.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 804e5be03..0fc69be3b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -77,12 +77,13 @@ CABLE_TERMINATION_MODELS = Q( ) COMPATIBLE_TERMINATION_TYPES = { + 'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'], 'consoleport': ['consoleserverport', 'frontport', 'rearport'], 'consoleserverport': ['consoleport', 'frontport', 'rearport'], - 'powerport': ['poweroutlet', 'powerfeed'], - 'poweroutlet': ['powerport'], 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], + 'powerfeed': ['powerport'], + 'poweroutlet': ['powerport'], + 'powerport': ['poweroutlet', 'powerfeed'], 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], - 'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'], } From 769b240164ab23da501d842fc5443bf734f035f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 11:11:41 -0400 Subject: [PATCH 149/291] Extend device component tables to include cable peer --- netbox/dcim/tables/devices.py | 46 +++++++++++++++++++---------- netbox/dcim/tables/power.py | 16 +++------- netbox/dcim/tables/template_code.py | 10 +++++++ 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c614c534b..334947263 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -10,7 +10,7 @@ from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, ) -from .template_code import DEVICE_LINK, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS +from .template_code import CABLETERMINATION, DEVICE_LINK, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS __all__ = ( 'ConsolePortTable', @@ -204,29 +204,40 @@ class DeviceComponentTable(BaseTable): order_by = ('device', 'name') -class ConsolePortTable(DeviceComponentTable): +class CableTerminationTable(BaseTable): + cable = tables.Column( + linkify=True + ) + cable_peer = tables.TemplateColumn( + accessor='get_cable_peer', + template_code=CABLETERMINATION, + orderable=False + ) + + +class ConsolePortTable(DeviceComponentTable, CableTerminationTable): tags = TagColumn( url_name='dcim:consoleport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsolePort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class ConsoleServerPortTable(DeviceComponentTable): +class ConsoleServerPortTable(DeviceComponentTable, CableTerminationTable): tags = TagColumn( url_name='dcim:consoleserverport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class PowerPortTable(DeviceComponentTable): +class PowerPortTable(DeviceComponentTable, CableTerminationTable): tags = TagColumn( url_name='dcim:powerport_list' ) @@ -234,19 +245,23 @@ class PowerPortTable(DeviceComponentTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', + 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') -class PowerOutletTable(DeviceComponentTable): +class PowerOutletTable(DeviceComponentTable, CableTerminationTable): tags = TagColumn( url_name='dcim:poweroutlet_list' ) class Meta(DeviceComponentTable.Meta): model = PowerOutlet - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer', + 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -265,7 +280,7 @@ class BaseInterfaceTable(BaseTable): ) -class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): +class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, CableTerminationTable): tags = TagColumn( url_name='dcim:interface_list' ) @@ -274,12 +289,12 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'description', 'cable', 'cable_peer', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') -class FrontPortTable(DeviceComponentTable): +class FrontPortTable(DeviceComponentTable, CableTerminationTable): rear_port_position = tables.Column( verbose_name='Position' ) @@ -290,19 +305,20 @@ class FrontPortTable(DeviceComponentTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', + 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') -class RearPortTable(DeviceComponentTable): +class RearPortTable(DeviceComponentTable, CableTerminationTable): tags = TagColumn( url_name='dcim:rearport_list' ) class Meta(DeviceComponentTable.Meta): model = RearPort - fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags') + fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 8d90fb620..471b1bb3a 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -3,6 +3,7 @@ from django_tables2.utils import Accessor from dcim.models import PowerFeed, PowerPanel from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn +from .devices import CableTerminationTable from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION __all__ = ( @@ -41,7 +42,7 @@ class PowerPanelTable(BaseTable): # Power feeds # -class PowerFeedTable(BaseTable): +class PowerFeedTable(CableTerminationTable): pk = ToggleColumn() name = tables.LinkColumn() power_panel = tables.Column( @@ -55,15 +56,6 @@ class PowerFeedTable(BaseTable): max_utilization = tables.TemplateColumn( template_code="{{ value }}%" ) - cable = tables.TemplateColumn( - template_code=POWERFEED_CABLE, - orderable=False - ) - connection = tables.TemplateColumn( - accessor='get_cable_peer', - template_code=POWERFEED_CABLETERMINATION, - orderable=False - ) available_power = tables.Column( verbose_name='Available power (VA)' ) @@ -75,9 +67,9 @@ class PowerFeedTable(BaseTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'cable', 'connection', 'available_power', 'tags', + 'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', - 'connection', + 'cable_peer', ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index b3840b48b..7278de113 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,3 +1,13 @@ +CABLETERMINATION = """ +{% if value %} + {{ value.parent }} + + {{ value }} +{% else %} + — +{% endif %} +""" + CABLE_LENGTH = """ {% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} """ From 35273c7bfe3623d00ddfa27f7d74db055088394e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 11:41:24 -0400 Subject: [PATCH 150/291] Add connection column for path endpoints --- netbox/dcim/tables/devices.py | 31 +++++++++++++++++++++---------- netbox/dcim/tables/power.py | 2 ++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 334947263..21c2f4244 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -215,29 +215,40 @@ class CableTerminationTable(BaseTable): ) -class ConsolePortTable(DeviceComponentTable, CableTerminationTable): +class PathEndpointTable(CableTerminationTable): + connection = tables.TemplateColumn( + accessor='_path.destination', + template_code=CABLETERMINATION, + verbose_name='Connection', + orderable=False + ) + + +class ConsolePortTable(DeviceComponentTable, PathEndpointTable): tags = TagColumn( url_name='dcim:consoleport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsolePort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'tags') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class ConsoleServerPortTable(DeviceComponentTable, CableTerminationTable): +class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): tags = TagColumn( url_name='dcim:consoleserverport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'tags') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class PowerPortTable(DeviceComponentTable, CableTerminationTable): +class PowerPortTable(DeviceComponentTable, PathEndpointTable): tags = TagColumn( url_name='dcim:powerport_list' ) @@ -246,12 +257,12 @@ class PowerPortTable(DeviceComponentTable, CableTerminationTable): model = PowerPort fields = ( 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', - 'cable_peer', 'tags', + 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') -class PowerOutletTable(DeviceComponentTable, CableTerminationTable): +class PowerOutletTable(DeviceComponentTable, PathEndpointTable): tags = TagColumn( url_name='dcim:poweroutlet_list' ) @@ -260,7 +271,7 @@ class PowerOutletTable(DeviceComponentTable, CableTerminationTable): model = PowerOutlet fields = ( 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer', - 'tags', + 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -280,7 +291,7 @@ class BaseInterfaceTable(BaseTable): ) -class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, CableTerminationTable): +class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): tags = TagColumn( url_name='dcim:interface_list' ) @@ -289,7 +300,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, CableTerminationT model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'cable', 'cable_peer', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 471b1bb3a..ae5c2a5c8 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -42,6 +42,8 @@ class PowerPanelTable(BaseTable): # Power feeds # +# We're not using PathEndpointTable for PowerFeed because power connections +# cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): pk = ToggleColumn() name = tables.LinkColumn() From 99352a5d30135168d82b34d87ffcb5d50810f515 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 14:39:15 -0400 Subject: [PATCH 151/291] Convert device console ports list to table --- netbox/dcim/tables/devices.py | 26 +++++++- netbox/dcim/tables/template_code.py | 21 ++++++ netbox/dcim/views.py | 5 +- netbox/templates/dcim/device.html | 27 ++------ netbox/templates/dcim/inc/consoleport.html | 77 ---------------------- 5 files changed, 55 insertions(+), 101 deletions(-) delete mode 100644 netbox/templates/dcim/inc/consoleport.html diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 21c2f4244..5f61e03ba 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -10,11 +10,14 @@ from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, ) -from .template_code import CABLETERMINATION, DEVICE_LINK, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS +from .template_code import ( + CABLETERMINATION, CONSOLEPORT_BUTTONS, DEVICE_LINK, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, +) __all__ = ( 'ConsolePortTable', 'ConsoleServerPortTable', + 'DeviceConsolePortTable', 'DeviceImportTable', 'DeviceTable', 'DeviceBayTable', @@ -237,6 +240,27 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') +class DeviceConsolePortTable(ConsolePortTable): + name = tables.TemplateColumn( + template_code=' {{ value }}' + ) + actions = ButtonsColumn( + model=ConsolePort, + buttons=('edit', 'delete'), + prepend_template=CONSOLEPORT_BUTTONS + ) + + class Meta(DeviceComponentTable.Meta): + model = ConsolePort + fields = ( + 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' + ) + default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'actions') + row_attrs = { + 'class': lambda record: record.cable.get_status_class() if record.cable else '' + } + + class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): tags = TagColumn( url_name='dcim:consoleserverport_list' diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 7278de113..e17a5a877 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -73,3 +73,24 @@ UTILIZATION_GRAPH = """ {% load helpers %} {% utilization_graph value %} """ + +# +# Device component buttons +# + +CONSOLEPORT_BUTTONS = """ +{% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} +{% elif perms.dcim.add_cable %} + + + + +{% endif %} +""" diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 06fb7619f..3cf011503 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1019,6 +1019,9 @@ class DeviceView(ObjectView): consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( 'cable', '_path__destination', ) + consoleport_table = tables.DeviceConsolePortTable(consoleports, orderable=False) + if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'): + consoleport_table.columns.show('pk') # Console server ports consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( @@ -1079,7 +1082,7 @@ class DeviceView(ObjectView): return render(request, 'dcim/device.html', { 'device': device, - 'consoleports': consoleports, + 'consoleport_table': consoleport_table, 'consoleserverports': consoleserverports, 'powerports': powerports, 'poweroutlets': poweroutlets, diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index c06f86bf0..1a74016b2 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -131,7 +131,7 @@ Rear Ports {% badge rearports|length %}
  • - Console Ports {% badge consoleports|length %} + Console Ports {% badge consoleport_table.rows|length %}
  • Console Server Ports {% badge consoleserverports|length %} @@ -670,27 +670,9 @@
    Console Ports
    - - - - {% if perms.dcim.change_consoleport or perms.dcim.delete_consoleport %} - - {% endif %} - - - - - - - - - - {% for cp in consoleports %} - {% include 'dcim/inc/consoleport.html' %} - {% endfor %} -
    NameTypeDescriptionCableCable TerminationConnection
    + {% include 'responsive_table.html' with table=consoleport_table %} {% endif %} +
  • diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html deleted file mode 100644 index ace09cfe2..000000000 --- a/netbox/templates/dcim/inc/consoleport.html +++ /dev/null @@ -1,77 +0,0 @@ - - - {# Checkbox #} - {% if perms.dcim.change_consoleport or perms.dcim.delete_consoleport %} - - - - {% endif %} - - {# Name #} - - - {{ cp }} - - - {# Type #} - - {% if cp.type %}{{ cp.get_type_display }}{% else %}—{% endif %} - - - {# Description #} - - {{ cp.description }} - - - {# Cable #} - {% if cp.cable %} - - {{ cp.cable }} - - - - - {% include 'dcim/inc/cabletermination.html' with termination=cp.get_cable_peer %} - {% else %} - - Not connected - - {% endif %} - - {# Connection #} - {% include 'dcim/inc/endpoint_connection.html' with path=cp.path %} - - {# Actions #} - - {% if cp.cable %} - {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %} - {% elif perms.dcim.add_cable %} - - - - - {% endif %} - {% if perms.dcim.change_consoleport %} - - - - {% endif %} - {% if perms.dcim.delete_consoleport %} - {% if cp.connected_endpoint %} - - {% else %} - - - - {% endif %} - {% endif %} - - From 60c30b92bab6f745d879feee6e031e755f979874 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 15:01:16 -0400 Subject: [PATCH 152/291] Convert device console server ports list to table --- netbox/dcim/tables/devices.py | 25 +++++- netbox/dcim/tables/template_code.py | 17 ++++ netbox/dcim/views.py | 6 +- netbox/templates/dcim/device.html | 30 ++----- .../templates/dcim/inc/consoleserverport.html | 79 ------------------- 5 files changed, 51 insertions(+), 106 deletions(-) delete mode 100644 netbox/templates/dcim/inc/consoleserverport.html diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 5f61e03ba..84460a632 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -11,13 +11,15 @@ from utilities.tables import ( TagColumn, ToggleColumn, ) from .template_code import ( - CABLETERMINATION, CONSOLEPORT_BUTTONS, DEVICE_LINK, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, + CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, INTERFACE_IPADDRESSES, + INTERFACE_TAGGED_VLANS, ) __all__ = ( 'ConsolePortTable', 'ConsoleServerPortTable', 'DeviceConsolePortTable', + 'DeviceConsoleServerPortTable', 'DeviceImportTable', 'DeviceTable', 'DeviceBayTable', @@ -272,6 +274,27 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') +class DeviceConsoleServerPortTable(ConsoleServerPortTable): + name = tables.TemplateColumn( + template_code=' {{ value }}' + ) + actions = ButtonsColumn( + model=ConsoleServerPort, + buttons=('edit', 'delete'), + prepend_template=CONSOLESERVERPORT_BUTTONS + ) + + class Meta(DeviceComponentTable.Meta): + model = ConsoleServerPort + fields = ( + 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' + ) + default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'actions') + row_attrs = { + 'class': lambda record: record.cable.get_status_class() if record.cable else '' + } + + class PowerPortTable(DeviceComponentTable, PathEndpointTable): tags = TagColumn( url_name='dcim:powerport_list' diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index e17a5a877..5e2b7e1c6 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -94,3 +94,20 @@ CONSOLEPORT_BUTTONS = """ {% endif %} """ + +CONSOLESERVERPORT_BUTTONS = """ +{% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} +{% elif perms.dcim.add_cable %} + + + + +{% endif %} +""" diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3cf011503..0de680420 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1029,6 +1029,10 @@ class DeviceView(ObjectView): ).prefetch_related( 'cable', '_path__destination', ) + consoleserverport_table = tables.DeviceConsoleServerPortTable(consoleserverports, orderable=False) + if request.user.has_perm('dcim.change_consoleserverport') or \ + request.user.has_perm('dcim.delete_consoleserverport'): + consoleserverport_table.columns.show('pk') # Power ports powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( @@ -1083,7 +1087,7 @@ class DeviceView(ObjectView): return render(request, 'dcim/device.html', { 'device': device, 'consoleport_table': consoleport_table, - 'consoleserverports': consoleserverports, + 'consoleserverport_table': consoleserverport_table, 'powerports': powerports, 'poweroutlets': poweroutlets, 'interfaces': interfaces, diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 1a74016b2..962d1127f 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -134,7 +134,7 @@ Console Ports {% badge consoleport_table.rows|length %}
  • - Console Server Ports {% badge consoleserverports|length %} + Console Server Ports {% badge consoleserverport_table.rows|length %}
  • Power Ports {% badge powerports|length %} @@ -707,29 +707,9 @@
    Console Server Ports
    - - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - - - - - {% for csp in consoleserverports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% endfor %} - -
    NameTypeDescriptionCableCable TerminationConnection
    + {% include 'responsive_table.html' with table=consoleserverport_table %} -
    {% endif %} +
  • diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html deleted file mode 100644 index 025b0bf02..000000000 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ /dev/null @@ -1,79 +0,0 @@ -{% load helpers %} - - - - {# Checkbox #} - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - - - {% endif %} - - {# Name #} - - - {{ csp }} - - - {# Type #} - - {% if csp.type %}{{ csp.get_type_display }}{% else %}—{% endif %} - - - {# Description #} - - {{ csp.description|placeholder }} - - - {# Cable #} - {% if csp.cable %} - - {{ csp.cable }} - - - - - {% include 'dcim/inc/cabletermination.html' with termination=csp.get_cable_peer %} - {% else %} - - Not connected - - {% endif %} - - {# Connection #} - {% include 'dcim/inc/endpoint_connection.html' with path=csp.path %} - - {# Actions #} - - {% if csp.cable %} - {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %} - {% elif perms.dcim.add_cable %} - - - - - {% endif %} - {% if perms.dcim.change_consoleserverport %} - - - - {% endif %} - {% if perms.dcim.delete_consoleserverport %} - {% if csp.connected_endpoint %} - - {% else %} - - - - {% endif %} - {% endif %} - - From 3a47e0e2edb68d5ab1f44bfe5449bf5d224bf433 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 15:09:23 -0400 Subject: [PATCH 153/291] Convert device power ports list to table --- netbox/dcim/tables/devices.py | 32 ++++++++- netbox/dcim/tables/template_code.py | 16 +++++ netbox/dcim/views.py | 5 +- netbox/templates/dcim/device.html | 26 ++------ netbox/templates/dcim/inc/powerport.html | 82 ------------------------ 5 files changed, 53 insertions(+), 108 deletions(-) delete mode 100644 netbox/templates/dcim/inc/powerport.html diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 84460a632..92d2be138 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -12,18 +12,19 @@ from utilities.tables import ( ) from .template_code import ( CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, INTERFACE_IPADDRESSES, - INTERFACE_TAGGED_VLANS, + INTERFACE_TAGGED_VLANS, POWERPORT_BUTTONS, ) __all__ = ( 'ConsolePortTable', 'ConsoleServerPortTable', + 'DeviceBayTable', 'DeviceConsolePortTable', 'DeviceConsoleServerPortTable', 'DeviceImportTable', - 'DeviceTable', - 'DeviceBayTable', + 'DevicePowerPortTable', 'DeviceRoleTable', + 'DeviceTable', 'FrontPortTable', 'InterfaceTable', 'InventoryItemTable', @@ -309,6 +310,31 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') +class DevicePowerPortTable(PowerPortTable): + name = tables.TemplateColumn( + template_code=' {{ value }}' + ) + actions = ButtonsColumn( + model=PowerPort, + buttons=('edit', 'delete'), + prepend_template=POWERPORT_BUTTONS + ) + + class Meta(DeviceComponentTable.Meta): + model = PowerPort + fields = ( + 'pk', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'cable_peer', + 'connection', 'tags', 'actions', + ) + default_columns = ( + 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer', + 'actions', + ) + row_attrs = { + 'class': lambda record: record.cable.get_status_class() if record.cable else '' + } + + class PowerOutletTable(DeviceComponentTable, PathEndpointTable): tags = TagColumn( url_name='dcim:poweroutlet_list' diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 5e2b7e1c6..121b33bd3 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -111,3 +111,19 @@ CONSOLESERVERPORT_BUTTONS = """ {% endif %} """ + +POWERPORT_BUTTONS = """ +{% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} +{% elif perms.dcim.add_cable %} + + + + +{% endif %} +""" diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0de680420..aa00ad1ff 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1038,6 +1038,9 @@ class DeviceView(ObjectView): powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( 'cable', '_path__destination', ) + powerport_table = tables.DevicePowerPortTable(powerports, orderable=False) + if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'): + powerport_table.columns.show('pk') # Power outlets poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( @@ -1088,7 +1091,7 @@ class DeviceView(ObjectView): 'device': device, 'consoleport_table': consoleport_table, 'consoleserverport_table': consoleserverport_table, - 'powerports': powerports, + 'powerport_table': powerport_table, 'poweroutlets': poweroutlets, 'interfaces': interfaces, 'frontports': frontports, diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 962d1127f..d9c1e55b8 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -137,7 +137,7 @@ Console Server Ports {% badge consoleserverport_table.rows|length %}
  • - Power Ports {% badge powerports|length %} + Power Ports {% badge powerport_table.rows|length %}
  • Power Outlets {% badge poweroutlets|length %} @@ -744,27 +744,9 @@
    Power Ports
    - - - - {% if perms.dcim.change_consoleport or perms.dcim.delete_consoleport %} - - {% endif %} - - - - - - - - - - {% for pp in powerports %} - {% include 'dcim/inc/powerport.html' %} - {% endfor %} -
    NameTypeDrawDescriptionCableConnection
    + {% include 'responsive_table.html' with table=powerport_table %}
  • - Power Outlets {% badge poweroutlets|length %} + Power Outlets {% badge poweroutlet_table.rows|length %}
  • Device Bays {% badge devicebays|length %} @@ -780,29 +780,9 @@
    Power Outlets
    - - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - - - - - {% for po in poweroutlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% endfor %} - -
    NameTypeInput/LegDescriptionCableConnection
    + {% include 'responsive_table.html' with table=poweroutlet_table %}
  • - Front Ports {% badge frontports|length %} + Front Ports {% badge frontport_table.rows|length %}
  • Rear Ports {% badge rearports|length %} @@ -542,8 +542,8 @@ Add interfaces
  • -
    {% endif %} +
    @@ -555,28 +555,7 @@
    Front Ports
    - - - - {% if perms.dcim.change_frontport or perms.dcim.delete_frontport %} - - {% endif %} - - - - - - - - - - - - {% for frontport in frontports %} - {% include 'dcim/inc/frontport.html' %} - {% endfor %} - -
    NameTypeRear PortPositionDescriptionCableCable Termination
    + {% include 'responsive_table.html' with table=frontport_table %} -
    {% endif %} +
    @@ -657,8 +636,8 @@ Add rear ports -
    {% endif %} +
    @@ -804,8 +783,8 @@ Add power outlets -
    {% endif %} +
    @@ -857,8 +836,8 @@ Add device bays -
    {% endif %} +
    diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html deleted file mode 100644 index 91374cb1e..000000000 --- a/netbox/templates/dcim/inc/frontport.html +++ /dev/null @@ -1,72 +0,0 @@ -{% load helpers %} - - - {# Checkbox #} - {% if perms.dcim.change_frontport or perms.dcim.delete_frontport %} - - - - {% endif %} - - {# Name #} - - - {{ frontport }} - - - {# Type #} - {{ frontport.get_type_display }} - - {# Rear port #} - {{ frontport.rear_port }} - {{ frontport.rear_port_position }} - - {# Description #} - {{ frontport.description|placeholder }} - - {# Cable #} - {% if frontport.cable %} - - {{ frontport.cable }} - - - - - {% include 'dcim/inc/cabletermination.html' with termination=frontport.get_cable_peer %} - {% else %} - - Not connected - - {% endif %} - - {# Actions #} - - {% if frontport.cable %} - {% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %} - {% elif perms.dcim.add_cable %} - - - - - {% endif %} - {% if perms.dcim.change_frontport %} - - - - {% endif %} - {% if perms.dcim.delete_frontport %} - - - - {% endif %} - - From e3f98a011c16e9543f9b5ab3ee62744ceb28c82a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 15:33:39 -0400 Subject: [PATCH 156/291] Convert device rear ports list to table --- netbox/dcim/tables/devices.py | 28 +++++++++- netbox/dcim/tables/template_code.py | 18 +++++++ netbox/dcim/views.py | 5 +- netbox/templates/dcim/device.html | 32 +++--------- netbox/templates/dcim/inc/rearport.html | 69 ------------------------- 5 files changed, 55 insertions(+), 97 deletions(-) delete mode 100644 netbox/templates/dcim/inc/rearport.html diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 95e459a47..d77b2cc88 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -12,7 +12,7 @@ from utilities.tables import ( ) from .template_code import ( CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, FRONTPORT_BUTTONS, - INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, POWERPORT_BUTTONS, + INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, POWERPORT_BUTTONS, REARPORT_BUTTONS, ) __all__ = ( @@ -25,6 +25,7 @@ __all__ = ( 'DeviceImportTable', 'DevicePowerPortTable', 'DevicePowerOutletTable', + 'DeviceRearPortTable', 'DeviceRoleTable', 'DeviceTable', 'FrontPortTable', @@ -458,6 +459,31 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') +class DeviceRearPortTable(RearPortTable): + name = tables.TemplateColumn( + template_code=' ' + '{{ value }}' + ) + actions = ButtonsColumn( + model=RearPort, + buttons=('edit', 'delete'), + prepend_template=REARPORT_BUTTONS + ) + + class Meta(DeviceComponentTable.Meta): + model = RearPort + fields = ( + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'connection', 'tags', + 'actions', + ) + default_columns = ( + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', + ) + row_attrs = { + 'class': lambda record: record.cable.get_status_class() if record.cable else '' + } + + class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index e7182e1da..9d717f794 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -157,3 +157,21 @@ FRONTPORT_BUTTONS = """ {% endif %} """ + +REARPORT_BUTTONS = """ +{% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} +{% elif perms.dcim.add_cable %} + + + + +{% endif %} +""" diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1056a8078..c09b6cca5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1067,6 +1067,9 @@ class DeviceView(ObjectView): # Rear ports rearports = RearPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related('cable') + rearport_table = tables.DeviceRearPortTable(rearports, orderable=False) + if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'): + rearport_table.columns.show('pk') # Device bays devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( @@ -1101,7 +1104,7 @@ class DeviceView(ObjectView): 'poweroutlet_table': poweroutlet_table, 'interfaces': interfaces, 'frontport_table': frontport_table, - 'rearports': rearports, + 'rearport_table': rearport_table, 'devicebays': devicebays, 'inventoryitems': inventoryitems, 'services': services, diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 94f66b345..693f3ccf8 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -128,7 +128,7 @@ Front Ports {% badge frontport_table.rows|length %}
  • - Rear Ports {% badge rearports|length %} + Rear Ports {% badge rearport_table.rows|length %}
  • Console Ports {% badge consoleport_table.rows|length %} @@ -557,7 +557,7 @@ {% include 'responsive_table.html' with table=frontport_table %}
  • - Interfaces {% badge interfaces|length %} + Interfaces {% badge interface_table.rows|length %}
  • Front Ports {% badge frontport_table.rows|length %} @@ -494,29 +494,7 @@ - - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% endfor %} - -
    NameLAGDescriptionMTUModeCableCable TerminationConnection
    + {% include 'responsive_table.html' with table=interface_table %}
  • - Device Bays {% badge devicebays|length %} + Device Bays {% badge devicebay_table.rows|length %}
  • Inventory {% badge inventoryitems|length %} @@ -754,36 +754,14 @@
    Device Bays
    - - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - - - {% for devicebay in devicebays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} - - - - {% endfor %} - -
    NameStatusDescriptionInstalled Device
    — No device bays defined —
    + {% include 'responsive_table.html' with table=devicebay_table %}
  • - Inventory {% badge inventoryitems|length %} + Inventory {% badge inventoryitem_table.rows|length %}
  • @@ -785,28 +785,7 @@
    Inventory Items
    - - - - {% if perms.dcim.change_inventoryitem or perms.dcim.delete_inventoryitem %} - - {% endif %} - - - - - - - - - - - - {% for item in inventoryitems %} - {% include 'dcim/inc/inventoryitem.html' %} - {% endfor %} - -
    NameManufacturerPart IDSerial NumberAsset TagDiscoveredDescription
    + {% include 'responsive_table.html' with table=inventoryitem_table %} {% endif %} +
    diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html deleted file mode 100644 index f7309fa59..000000000 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ /dev/null @@ -1,40 +0,0 @@ -{% load helpers %} - - - {# Checkbox #} - {% if perms.dcim.change_inventoryitem or perms.dcim.delete_inventoryitem %} - - - - {% endif %} - - - {{ item }} - - - {% if item.manufacturer %} - {{ item.manufacturer }} - {% else %} - - {% endif %} - - {{ item.part_id|placeholder }} - {{ item.serial|placeholder }} - {{ item.asset_tag|placeholder }} - - {% if item.discovered %} - - {% else %} - - {% endif %} - - {{ item.description|placeholder }} - - {% if perms.dcim.change_inventoryitem %} - - {% endif %} - {% if perms.dcim.delete_inventoryitem %} - - {% endif %} - - From 51821818e0c39e88f74b6402be72e8a1681da8b2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 16:14:05 -0400 Subject: [PATCH 160/291] Add cable trace buttons --- netbox/dcim/tables/template_code.py | 39 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index ac8ecca76..604174701 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -80,6 +80,7 @@ UTILIZATION_GRAPH = """ CONSOLEPORT_BUTTONS = """ {% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% elif perms.dcim.add_cable %} @@ -97,6 +98,7 @@ CONSOLEPORT_BUTTONS = """ CONSOLESERVERPORT_BUTTONS = """ {% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% elif perms.dcim.add_cable %} @@ -114,6 +116,7 @@ CONSOLESERVERPORT_BUTTONS = """ POWERPORT_BUTTONS = """ {% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% elif perms.dcim.add_cable %} @@ -130,6 +133,7 @@ POWERPORT_BUTTONS = """ POWEROUTLET_BUTTONS = """ {% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% elif perms.dcim.add_cable %} @@ -144,27 +148,27 @@ INTERFACE_BUTTONS = """ {% endif %} -{% if perms.dcim.change_interface %} - {% if record.cable %} - {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} - {% elif record.is_connectable and perms.dcim.add_cable %} - - - - - {% endif %} +{% if record.cable %} + + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} +{% elif record.is_connectable and perms.dcim.add_cable %} + + + + {% endif %} """ FRONTPORT_BUTTONS = """ -{% if frontport.cable %} +{% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% elif perms.dcim.add_cable %} @@ -185,6 +189,7 @@ FRONTPORT_BUTTONS = """ REARPORT_BUTTONS = """ {% if record.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% elif perms.dcim.add_cable %} From 0a67926012f51a6ecef39e5b2c851721c7b55ba9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 16:33:08 -0400 Subject: [PATCH 161/291] Fix up missing table columns --- netbox/dcim/tables/devices.py | 36 ++++++++++++++++++++--------- netbox/dcim/tables/template_code.py | 10 ++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index e318babb0..185ad9b32 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -11,9 +11,9 @@ from utilities.tables import ( TagColumn, ToggleColumn, ) from .template_code import ( - CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, FRONTPORT_BUTTONS, - INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, POWERPORT_BUTTONS, - REARPORT_BUTTONS, + CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, + FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, + POWERPORT_BUTTONS, REARPORT_BUTTONS, ) __all__ = ( @@ -343,6 +343,9 @@ class DevicePowerPortTable(PowerPortTable): class PowerOutletTable(DeviceComponentTable, PathEndpointTable): + power_port = tables.Column( + linkify=True + ) tags = TagColumn( url_name='dcim:poweroutlet_list' ) @@ -415,6 +418,10 @@ class DeviceInterfaceTable(InterfaceTable): '{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}exchange' '{% endif %}"> {{ value }}' ) + lag = tables.Column( + linkify=True, + verbose_name='LAG' + ) actions = ButtonsColumn( model=Interface, buttons=('edit', 'delete'), @@ -424,11 +431,11 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description', 'cable', - 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description', + 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'enabled', 'description', 'cable', 'cable_peer', 'actions', + 'pk', 'name', 'label', 'enabled', 'lag', 'type', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { 'class': lambda record: record.cable.get_status_class() if record.cable else '' @@ -439,6 +446,9 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): rear_port_position = tables.Column( verbose_name='Position' ) + rear_port = tables.Column( + linkify=True + ) tags = TagColumn( url_name='dcim:frontport_list' ) @@ -514,6 +524,9 @@ class DeviceRearPortTable(RearPortTable): class DeviceBayTable(DeviceComponentTable): + status = tables.TemplateColumn( + template_code=DEVICEBAY_STATUS + ) installed_device = tables.Column( linkify=True ) @@ -523,8 +536,8 @@ class DeviceBayTable(DeviceComponentTable): class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') + fields = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description', 'tags') + default_columns = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description') class DeviceDeviceBayTable(DeviceBayTable): @@ -541,10 +554,10 @@ class DeviceDeviceBayTable(DeviceBayTable): class Meta(DeviceComponentTable.Meta): model = DeviceBay fields = ( - 'pk', 'name', 'label', 'installed_device', 'description', 'tags', 'actions', + 'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'installed_device', 'description', 'actions', + 'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions', ) @@ -580,7 +593,8 @@ class DeviceInventoryItemTable(DeviceBayTable): class Meta(DeviceComponentTable.Meta): model = InventoryItem fields = ( - 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', 'actions', + 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', + 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'actions', diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 604174701..fcfeb0147 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -28,6 +28,16 @@ DEVICE_LINK = """ """ +DEVICEBAY_STATUS = """ +{% if record.installed_device_id %} + + {{ record.installed_device.get_status_display }} + +{% else %} + Vacant +{% endif %} +""" + INTERFACE_IPADDRESSES = """ {% for ip in record.ip_addresses.unrestricted %} {{ ip }}
    From a969b81e6313e0eab2e82d71e8ab10603d35fcfa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 16:36:12 -0400 Subject: [PATCH 162/291] Change color for edit button --- netbox/utilities/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 24a8284f1..5fd3a3a79 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -142,7 +142,7 @@ class ButtonsColumn(tables.TemplateColumn): {{% endif %}} {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - + {{% endif %}} From 00caa368c58a7646349aa26855790ac734ba60ac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 16:40:21 -0400 Subject: [PATCH 163/291] Remove interface IPs toggle --- netbox/project-static/js/interface_toggles.js | 13 ------------- netbox/templates/dcim/device.html | 5 ----- netbox/templates/virtualization/virtualmachine.html | 5 ----- 3 files changed, 23 deletions(-) diff --git a/netbox/project-static/js/interface_toggles.js b/netbox/project-static/js/interface_toggles.js index df8ac064b..bf205b92a 100644 --- a/netbox/project-static/js/interface_toggles.js +++ b/netbox/project-static/js/interface_toggles.js @@ -1,16 +1,3 @@ -// Toggle the display of IP addresses under interfaces -$('button.toggle-ips').click(function() { - var selected = $(this).attr('selected'); - if (selected) { - $('#interfaces_table tr.interface:visible + tr.ipaddresses').hide(); - } else { - $('#interfaces_table tr.interface:visible + tr.ipaddresses').show(); - } - $(this).attr('selected', !selected); - $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); - return false; -}); - // Inteface filtering $('input.interface-filter').on('input', function() { var filter = new RegExp(this.value); diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d7b35d3d7..7253bdcae 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -485,11 +485,6 @@
    Interfaces -
    - -
    diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 7eabcf504..b72eec571 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -276,11 +276,6 @@
    Interfaces -
    - -
    From 502b66367c176db17e92694eed6e3c2453a1a73a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Oct 2020 17:01:55 -0400 Subject: [PATCH 164/291] Convert VM interfaces list to table --- .../virtualization/inc/vminterface.html | 136 ------------------ .../virtualization/virtualmachine.html | 25 +--- netbox/virtualization/tables.py | 34 +++++ netbox/virtualization/views.py | 8 +- 4 files changed, 41 insertions(+), 162 deletions(-) delete mode 100644 netbox/templates/virtualization/inc/vminterface.html diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html deleted file mode 100644 index 93efafb5a..000000000 --- a/netbox/templates/virtualization/inc/vminterface.html +++ /dev/null @@ -1,136 +0,0 @@ -{% load helpers %} - - - {# Checkbox #} - {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} - - - - {% endif %} - - {# Name #} - - {{ iface }} - - - {# MAC address #} - - {{ iface.mac_address|default:"—" }} - - - {# MTU #} - {{ iface.mtu|default:"—" }} - - {# 802.1Q mode #} - {{ iface.get_mode_display|default:"—" }} - - {# Description/tags #} - - {% if iface.description %} - {{ iface.description }}
    - {% endif %} - {% for tag in iface.tags.all %} - {% tag tag %} - {% empty %} - {% if not iface.description %}—{% endif %} - {% endfor %} - - - {# Buttons #} - - {% if perms.ipam.add_ipaddress %} - - - - {% endif %} - {% if perms.virtualization.change_vminterface %} - - - - {% endif %} - {% if perms.virtualization.delete_vminterface %} - - - - {% endif %} - - - -{% with ipaddresses=iface.ip_addresses.all %} - {% if ipaddresses %} - - {# Placeholder #} - {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} - - {% endif %} - - {# IP addresses table #} - - - - - - - - - - - - {% for ip in iface.ip_addresses.all %} - - - {# IP address #} - - - {# Primary/status/role #} - - - {# VRF #} - - - {# Description #} - - - {# Buttons #} - - - - {% endfor %} -
    IP AddressStatus/RoleVRFDescription
    - {{ ip }} - - {% if virtualmachine.primary_ip4 == ip or virtualmachine.primary_ip6 == ip %} - Primary - {% endif %} - {{ ip.get_status_display }} - {% if ip.role %} - {{ ip.get_role_display }} - {% endif %} - - {% if ip.vrf %} - {{ ip.vrf.name }} - {% else %} - Global - {% endif %} - - {% if ip.description %} - {{ ip.description }} - {% else %} - - {% endif %} - - {% if perms.ipam.change_ipaddress %} - - - - {% endif %} - {% if perms.ipam.delete_ipaddress %} - - - - {% endif %} -
    - - - {% endif %} -{% endwith %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index b72eec571..a2213aee1 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -280,30 +280,7 @@
    - - - - {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} - - {% endif %} - - - - - - - - - - {% for iface in interfaces %} - {% include 'virtualization/inc/vminterface.html' %} - {% empty %} - - - - {% endfor %} - -
    NameMAC AddressMTUModeDescription
    — No interfaces defined —
    + {% include 'responsive_table.html' with table=vminterface_table %} {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %}