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 f314c53717..8990f83e04 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 0000000000..eb25d083bc --- /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 9d53806557..074cb82c5b 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 eb25d083bc..d289c7a05c 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 a198d03d56..de6a9b470c 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 aa8f6ba691..a186f772f3 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 e8962da010..288be10e24 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 0000000000..68ff8f5ab1 --- /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 e57caf091a..40ba64a676 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 @@ def render(self, queryset): 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 72db138e24..deb41d6280 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -55,9 +55,9 @@ def setUpTestData(cls): 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 @@ def test_content_type(self): 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 fd50f6b5a3..d49e41b768 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -132,17 +132,6 @@ 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 e3551b91ff..0000000000 --- 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 d289c7a05c..bc7c1a1a18 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 a94aa3cc46..bd8fc780d4 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 746ee02f67..cd73a614dc 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 cdec41d1fd..14f555cc6a 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -22,7 +22,7 @@ ) -@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 fa264aaa2e..8a62894017 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 @@ def setUpTestData(cls): ) 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 4d02ef0112..e5da5100fd 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 @@ def get(self, request, slug): ).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 @@ def get(self, request, slug): 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 f5b37021d0..0583d4e56d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -23,9 +23,7 @@ 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 @@ def get_serializer_class(self): 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 92b0605e9a..9bd7cdc8b7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -582,7 +582,7 @@ class Meta: 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 4189e04466..8bb56101e6 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -450,7 +450,7 @@ def to_csv(self): ) -@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 daf7055db6..1ea083367b 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -91,7 +91,7 @@ def to_objectchange(self, action): # 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 c3ffecdfff..22085fdbdc 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 @@ 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 @@ def setUpTestData(cls): }, ] - @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 @@ def setUpTestData(cls): }, ] - @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 @@ def setUpTestData(cls): }, ] - @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 c016f6e543..c3cfa105fa 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,7 +14,6 @@ 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 @@ def get(self, request, slug): 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 @@ def get(self, request, pk): '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 de6a9b470c..5c95025cd3 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,7 +2,7 @@ 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 198a5d2f84..95c9807689 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -7,7 +7,6 @@ __all__ = [ 'NestedConfigContextSerializer', 'NestedExportTemplateSerializer', - 'NestedGraphSerializer', 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', 'NestedTagSerializer', @@ -30,14 +29,6 @@ class Meta: 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 a186f772f3..1942f6f252 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -10,7 +10,7 @@ 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 .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 9c50c9a45c..70e5bc9dab 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -8,9 +8,6 @@ # 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 289a51c831..5fa26a0d71 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -15,7 +15,7 @@ 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 @@ def get_queryset(self): 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 b147481354..7b4b1665a2 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 a506d5867d..190e68c36e 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -6,7 +6,6 @@ 'custom_fields', 'custom_links', 'export_templates', - 'graphs', 'job_results', 'webhooks' ] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 288be10e24..73811c0635 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -7,7 +7,7 @@ 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 @@ 'CustomFieldFilter', 'CustomFieldFilterSet', 'ExportTemplateFilterSet', - 'GraphFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', @@ -90,13 +89,6 @@ def __init__(self, *args, **kwargs): 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 0000000000..c884c8f823 --- /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 e520581573..9437fe01f7 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 @@ 'CustomFieldValue', 'CustomLink', 'ExportTemplate', - 'Graph', 'ImageAttachment', 'JobResult', 'ObjectChange', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 40ba64a676..8c88bb1da5 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -203,69 +203,6 @@ def __str__(self): 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 c768534a2a..f66fea2ace 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -10,7 +10,7 @@ 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 @@ def test_root(self): 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 deb41d6280..cc702f07bf 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -2,49 +2,12 @@ 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 22e6e1f8fe..6a02872f8e 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 4405c29034..0000000000 --- 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 42c322ce29..e0598d872d 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 @@

{{ provider }}

{% 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 e97893c301..09f6eab402 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 @@

{{ device }}

}); - {% endblock %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index ce66d9da25..a317dc937d 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 d6c21bf923..a0fbd59ec5 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -35,12 +35,6 @@ {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html index 5410fba7a4..0f672b7292 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 1bf41c2b79..9d210459e5 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 f787aef0e4..fb61c5b9ee 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -381,7 +381,7 @@ def site(self): # 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 6ddcdf2ef2..28d4bbb99d 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 @@ def setUpTestData(cls): '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 0000000000..c72b937dbe --- /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 0000000000..58c3209028 --- /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 0000000000..9b2402267b --- /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 fd09971b64..1c86812e03 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -17,6 +17,10 @@ # 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 0000000000..c9d66975cd --- /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 0000000000..33b5f06e18 --- /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 0000000000..4d05a82728 --- /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 0000000000..5c129e2abd --- /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 51a06156a6..2a8d31494c 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 14f555cc6a..fbe568f18f 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 8bb56101e6..b10fd6b740 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 f760fea132..08ae194ae2 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 3169272b48..e30481cd6e 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 1ea083367b..0d33da8067 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 5ef983977d..a0238129ba 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -8,7 +8,7 @@ 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 @@ def _populate_custom_fields(self, instance, custom_fields): 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 5fa26a0d71..5be8276b6a 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -93,10 +93,6 @@ def get_serializer_context(self): }) 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 90ec828c7f..40c675c4dd 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -12,7 +12,7 @@ ) 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 @@ def _append_customfield_fields(self): """ # 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 @@ def _append_customfield_fields(self): 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 0000000000..3369289a0f --- /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 9437fe01f7..a4178b9115 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 @@ 'CustomField', 'CustomFieldChoice', 'CustomFieldModel', - 'CustomFieldValue', 'CustomLink', 'ExportTemplate', 'ImageAttachment', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 1c86812e03..b0ea76cefd 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 @@ def __init__(self, *args, custom_fields=None, **kwargs): 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 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= 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 @@ 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, 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 9d9b55778d..b7019897bc 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 8c9d09564e..5d1dee864b 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -5,7 +5,7 @@ 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 @@ def test_update_object(self): 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 @@ def test_update_object(self): 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 e543b63e3c..74c0e7c3bf 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -7,7 +7,7 @@ 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 @@ def test_simple_fields(self): # 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 @@ def test_select_field(self): # 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 @@ def setUpTestData(cls): 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 @@ def test_import(self): # 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 @@ def test_import(self): # 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 @@ def test_import(self): # 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 58dd960896..e5ae122abf 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 6209b57002..23a883103d 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -6,7 +6,6 @@ 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 cc3abf19a4..5a6108e091 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 c7db2f6494..51c7a26f9a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -26,8 +26,7 @@ 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 @@ def queryset_to_csv(self): 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 @@ def get(self, request): 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 @@ def post(self, request, **kwargs): 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 fb61c5b9ee..00d444de99 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 a0238129ba..df00d0c1d8 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -148,10 +148,10 @@ def __init__(self, *args, **kwargs): 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 40c675c4dd..96290ef0a3 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -57,26 +57,13 @@ def _append_customfield_fields(self): # 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) - - 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 + self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name) - 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 b0ea76cefd..166ef5708e 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 @@ def cf(self): """ 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 74c0e7c3bf..71254ac05d 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -174,7 +174,7 @@ def setUpTestData(cls): } 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 @@ def test_get_single_object_without_custom_field_values(self): '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 @@ def test_create_single_object_with_defaults(self): # 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 @@ def test_create_single_object_with_values(self): # 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 @@ def test_create_multiple_objects_with_defaults(self): # 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 @@ def test_create_multiple_objects_with_values(self): # 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 @@ def test_update_single_object_with_values(self): # 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 @@ def test_import(self): 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 df00d0c1d8..536b654395 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -2,7 +2,6 @@ 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 @@ def _populate_custom_fields(self, instance, custom_fields): 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 71254ac05d..16785854fb 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 @@ def test_simple_fields(self): {'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 @@ def test_create_single_object_with_defaults(self): 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 @@ def test_create_single_object_with_values(self): 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 @@ def test_create_multiple_objects_with_defaults(self): 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 @@ def test_create_multiple_objects_with_values(self): 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 @@ def test_import(self): 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 @@ def test_import(self): 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 5c95025cd3..4bd738160f 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,7 +2,7 @@ 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 @@ def __init__(self, *args, **kwargs): 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 536b654395..a053642db9 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -7,7 +7,7 @@ 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 @@ def __call__(self, serializer_field): 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 @@ def to_internal_value(self, data): 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 @@ def to_internal_value(self, data): 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 @@ def to_internal_value(self, data): 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 @@ def __init__(self, *args, **kwargs): 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 70e5bc9dab..20d0f8d171 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,9 +5,6 @@ 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 5be8276b6a..278caeda74 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -14,9 +14,7 @@ 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 @@ def get_view_name(self): 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 @@ def get_serializer_context(self): # 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 0000000000..1ae63a2c35 --- /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 9b2402267b..017201259f 100644 --- a/netbox/extras/migrations/0050_migrate_customfieldvalues.py +++ b/netbox/extras/migrations/0051_migrate_customfields.py @@ -3,18 +3,38 @@ 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 3369289a0f..8b5e2ba458 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 a4178b9115..c6191bbd22 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 @@ 'ConfigContext', 'ConfigContextModel', 'CustomField', - 'CustomFieldChoice', 'CustomFieldModel', 'CustomLink', 'ExportTemplate', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 166ef5708e..e922a2f778 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -3,6 +3,7 @@ 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.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 Meta: 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 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= # 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 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= 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 16785854fb..675248a3b0 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -5,7 +5,7 @@ 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 @@ def test_select_field(self): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def test_get_single_object_with_custom_field_data(self): 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 @@ def test_create_single_object_with_defaults(self): 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 @@ def test_create_single_object_with_defaults(self): 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 @@ def test_create_single_object_with_values(self): '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 @@ def test_create_multiple_objects_with_defaults(self): 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 @@ def test_create_multiple_objects_with_defaults(self): 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 @@ def test_create_multiple_objects_with_values(self): '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 @@ def test_update_single_object_with_values(self): # 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 @@ def test_update_single_object_with_values(self): 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 @@ def setUpTestData(cls): 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 96290ef0a3..2cdd5d2edf 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -25,34 +25,23 @@ def __init__(self, *args, **kwargs): 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 e922a2f778..325853d6d5 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 @@ def clean(self): '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 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= 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 c72b937dbe..97da9962cc 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 58c3209028..e6353c9168 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 325853d6d5..422438539d 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 c9d66975cd..86d51e9b8e 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 33b5f06e18..6d48e7cab7 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 4d05a82728..ec05be0ff8 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 5c129e2abd..9a120406ae 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 d49e41b768..92f39cf447 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -486,7 +486,6 @@ def _setting(name, default=None): '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 38297838db..e013bf82ad 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -5,7 +5,6 @@ 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 @@ def field_to_swagger_object(self, field, swagger_object_type, use_references, ** 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 a053642db9..393e4545f4 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 @@ def __call__(self, serializer_field): 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 675248a3b0..31d3c2be98 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -393,19 +393,17 @@ def test_update_single_object_with_values(self): # 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 @@ def test_import(self): 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 @@ def test_import(self): 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 31d3c2be98..38d904d412 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -466,9 +466,9 @@ def test_import(self): 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 73811c0635..c7116fa0d9 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -21,15 +21,20 @@ '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 @@ def filter(self, queryset, value): 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 393e4545f4..52c9a18ab0 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 @@ def __call__(self, serializer_field): 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 92f39cf447..7bcc806d71 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -484,6 +484,7 @@ def _setting(name, default=None): 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 e013bf82ad..bee2f7d929 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -5,6 +5,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 @@ -60,6 +61,17 @@ def process_result(self, result, method_name, obj, **kwargs): 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 7bcc806d71..66aabfcf5b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -487,6 +487,7 @@ def _setting(name, default=None): '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 bee2f7d929..1d5c9c0a05 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -49,6 +49,43 @@ def field_to_swagger_object(self, field, swagger_object_type, use_references, ** 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 05f6d825e4..235e39a8f2 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 807b9b1e65..34274342ce 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 be43ac2a6a..756e320af1 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 04ac417dbf..1ea6139976 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 b704ad7fc0..f4db3c84d2 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 cc38fdb5ec..a3d5000949 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 bd8fc780d4..b3f8ae1bcb 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 dc12e686ec..fa4f817929 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 cef95a7b6e..93c44f087e 100644 --- a/netbox/dcim/elevations.py +++ b/netbox/dcim/elevations.py @@ -94,8 +94,12 @@ def _draw_device_front(self, drawing, device, start, end, text): # 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 @@ def _draw_device_rear(self, drawing, device, start, end, text): # 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 @@ def merge_elevations(self, face): 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 6dd5cb6bf3..43f77de515 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1811,7 +1811,7 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 Meta: 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 0000000000..594f6b9a43 --- /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 9bd7cdc8b7..4d79f34346 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -702,18 +702,12 @@ def clean(self): }) # 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 8bb56101e6..c152e7c04d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -633,7 +633,7 @@ def validate_unique(self, exclude=None): # 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 3169272b48..6c5ab08b99 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 e48eaedba2..371eff9db9 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -152,6 +152,10 @@ {% endfor %} """ +CONNECTION_STATUS = """ +{{ record.get_connection_status_display }} +""" + # # Regions @@ -706,34 +710,48 @@ class Meta(BaseTable.Meta): 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 c3cfa105fa..f9a04aede9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1030,7 +1030,7 @@ def get(self, request, pk): ) # 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 5ef983977d..f096fb4a6d 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -158,7 +158,7 @@ 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: + 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 5fa26a0d71..5a7cfa482d 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 73811c0635..b37701d9d7 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -7,7 +7,7 @@ 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 @@ 'CustomFieldFilter', 'CustomFieldFilterSet', 'ExportTemplateFilterSet', + 'ImageAttachmentFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', @@ -96,6 +97,13 @@ class Meta: 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 cc702f07bf..e96293b203 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 @@ def test_content_type(self): 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 0d273e4d85..dd0731bb8f 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -88,7 +88,7 @@ def get_serializer_class(self): 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 @@ def available_ips(self, request, pk=None): 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 68fdfd9dfb..f3ff19ddc2 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 58dd960896..832e09330e 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 @@ def clean(self): '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 5a4e2c1331..d7a64f7db9 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -67,11 +67,7 @@ """ IPADDRESS_ASSIGN_LINK = """ -{% if request.GET %} - {{ record }} -{% else %} - {{ record }} -{% endif %} +{{ record }} """ VRF_LINK = """ @@ -103,7 +99,7 @@ """ 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 8ea33764c6..1f0e2607ed 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 @@ def post(self, request): 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 d49e41b768..bd247070fa 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ # Environment setup # -VERSION = '2.9.1-dev' +VERSION = '2.9.4-dev' # Hostname HOSTNAME = platform.node() @@ -132,7 +132,6 @@ 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 bd59b72332..61115cbab3 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 0000000000..e0f1a23264 --- /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 dc2111b8ab..6fa5e8b912 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 dcf168ae77..fca1fa5f46 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 ee6a66d8fd..bde7b8641d 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 d9a77d6474..5800f4b48b 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 58eed145a5..b30fc8456f 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 eaffe2bcaf..7a5c999053 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 0df4a5e8a5..3d9a46a528 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 b8ec3878a1..ed9692eea3 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 5230b25947..0bd051161c 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 0f672b7292..93efafb5ab 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 aafefffa15..11b120ee0b 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 6b0313284a..12018ba5d6 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 7552324440..46221f6499 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -38,6 +38,10 @@ def dispatch(self, *args, **kwargs): 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 @@ def post(self, request): 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 @@ def post(self, request): 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 @@ def post(self, request): '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 0144ea2d1d..6df6b2e269 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -44,7 +44,7 @@ def __init__(self, *args, columns=None, **kwargs): 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 c7db2f6494..0790686487 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 a64a0a7d87..5d002deccf 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 @@ def __init__(self, *args, **kwargs): # 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 @@ def clean_enabled(self): 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 fb61c5b9ee..0845fac7ed 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -335,13 +335,13 @@ def clean(self): 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 039934d703..5f5b9326de 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 cc9789161e..acafd81bda 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -8,13 +8,13 @@ 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 @@ def to_internal_value(self, data): ) +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 @@ def perform_destroy(self, instance): 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 bf6ebd7ff0..2c6c70fea5 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -300,6 +300,29 @@ def test_delete_object(self): 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 81baadb7af..fbb7830e22 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 422438539d..eac481bef5 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -116,6 +116,17 @@ class Meta: 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 e10c41d34d..d4e187b5c5 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -3,12 +3,14 @@ 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 4bd738160f..9af9077acf 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -83,13 +83,14 @@ def __init__(self, *args, **kwargs): @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 eac481bef5..bb577fd4b8 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -159,15 +159,11 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= 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 9af9077acf..a8b4c0eb8c 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,6 +2,7 @@ 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 @@ def __init__(self, *args, **kwargs): 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 bc7c1a1a18..45c1921485 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 45c1921485..e9dfbc0c39 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 50c1f99ff3..1b2f752014 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 Meta: # 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 Meta: 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 @@ def get_interface_a(self, obj): # 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 43f77de515..1599b343d7 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 @@ def clean_termination_b_id(self): 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 @@ def save(self, *args, **kwargs): 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 cc7b8cc7fc..34148bd0ac 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 8b4f06108f..38ed843703 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -882,8 +882,8 @@ def get_status_class(self): # 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 @@ def get_compatible_types(self): # 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 08ae194ae2..1b226586f5 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -22,8 +22,8 @@ # 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 4a6b562983..f09f8c8286 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -561,8 +561,8 @@ def get_power_utilization(self): 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 03188163dd..bdae87c486 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -83,6 +83,7 @@

    {% block title %}Cable {{ cable }}{% endblock %}

    + {% 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 98eca17d24..c0ade9aecf 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 90956d2a39..3cad8b5b30 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -82,6 +82,7 @@

    {% block title %}{{ powerpanel }}{% endblock %}

    + {% 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 ab0fc0bbac..5e607bcabd 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -124,6 +124,7 @@

    {% block title %}{{ rackreservation }}{% endblock %}

    + {% 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 d6fa9cfcb4..fd030d1fed 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 18ec8c4e7c..c38761d689 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -78,6 +78,7 @@

    {% block title %}{{ virtualchassis }}{% endblock %}

    + {% 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 54bdc9fe84..3b24cce20c 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -21,9 +21,20 @@

    {% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% e
    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 e9dfbc0c39..48f5ae1d74 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 3bc9539918..40b03ada6f 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -305,10 +305,11 @@ class Meta: 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 1b2f752014..7256393212 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 0000000000..2e7b34a8d0 --- /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 0000000000..4bd0c770fe --- /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 4d79f34346..57c611bc96 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -6,6 +6,7 @@ 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 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 @@ def clean(self): # @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 22085fdbdc..286405e546 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1273,7 +1273,7 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 d4504d5862..0a2794f01f 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -2285,14 +2285,16 @@ def setUpTestData(cls): 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 066ea1b029..7afde8ed25 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1430,11 +1430,9 @@ def setUpTestData(cls): 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 f9a04aede9..0e322b2d37 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1089,10 +1089,8 @@ 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, 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 69afbb6a18..3668d30528 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 1b103893f2..9bcfffa727 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 48f5ae1d74..4291b641d5 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 1599b343d7..1096ae6a74 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 9bcfffa727..d56ae03c90 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 4291b641d5..54a6fcd390 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 63ae5d2a43..aa0453bafb 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -189,7 +189,6 @@ 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 0e322b2d37..31cc66bde5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1050,6 +1050,11 @@ def get(self, request, pk): '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 @@ def get(self, request, pk): '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 @@ def get(self, request, pk): }) -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 09f6eab402..ff82a49e27 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 @@

    {{ device }}

    - {% 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 @@

    {{ device }}

    {% 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 3668d30528..0000000000 --- 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 d56ae03c90..f7309fa59e 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 54a6fcd390..bcd9a55b89 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 bcd9a55b89..d2bdf983ba 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 7afde8ed25..83d8841df0 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -983,9 +983,9 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 35bae013a9..7992e4ddf6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -194,9 +194,9 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 9d63788ba9..9a36e1e4bc 100644 --- a/netbox/templates/utilities/obj_bulk_import.html +++ b/netbox/templates/utilities/obj_bulk_import.html @@ -66,7 +66,7 @@

    {% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}

    {% endif %} - {% if field.choice_values %} + {% if field.STATIC_CHOICES %} @@ -77,9 +77,12 @@

    {% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}

    - + + + {% 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 6146e00d3a..d154454126 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 38ed843703..3d7963dbb3 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 78f25952ae..9ccc86de47 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 0000000000..4758a90842 --- /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 23a883103d..2a14aef591 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -6,13 +6,14 @@ 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 @@ def to_csv(self): 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 5e8c5a8b44..7158b0b134 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -28,12 +28,15 @@ class Meta(BaseTable.Meta): 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 2872616b8a..e3d6077550 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 841d9843a0..ce7c37c40a 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -11,7 +11,8 @@ @@ -50,9 +51,9 @@

    {% block title %}{{ secret }}{% endblock %}

    - + 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 8f04edc5bd..b391b59e67 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -11,6 +11,7 @@ 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 Meta: 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 @@ def __init__(self, *args, **kwargs): 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 e3d6077550..2872616b8a 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 ff82a49e27..6f8ae69c68 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -395,34 +395,8 @@

    {{ device }}

    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 0000000000..11ad5e75d2 --- /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 2af609289b..0000000000 --- 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 0cb1eefef1..d3c2f88dc6 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -18,9 +18,24 @@

    {% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Secret{% en

    {% 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 ea33aa4604..3f0a37b88f 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -220,6 +220,9 @@

    {% block title %}{{ virtualmachine }}{% endblock %}

    + {% if perms.secrets.view_secret %} + {% include 'secrets/inc/assigned_secrets.html' %} + {% endif %}
    Services @@ -325,8 +328,10 @@

    {% block title %}{{ virtualmachine }}{% endblock %}

    {% 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 a492370eff..e81ee1e49b 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 a06a2e5ff3..5e4b99553a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -9,6 +9,7 @@ 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 4758a90842..02a0e0e211 100644 --- a/netbox/secrets/migrations/0011_secret_generic_assignments.py +++ b/netbox/secrets/migrations/0011_secret_generic_assignments.py @@ -4,9 +4,10 @@ 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 2a14aef591..f5508d47d1 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 9ccc86de47..003859b2f8 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -4,6 +4,7 @@ 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 b7ac73f1d0..0be1ef594a 100644 --- a/netbox/secrets/tests/test_filters.py +++ b/netbox/secrets/tests/test_filters.py @@ -3,6 +3,7 @@ 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 @@ def setUpTestData(cls): ) 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 @@ def setUpTestData(cls): 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 @@ def test_name(self): 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 @@ def test_device(self): 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 2862259d87..1fd3f19ef9 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 Meta: 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 7db6f92b67..33cddea2bd 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 a1c3cb3da4..16803820ee 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 b391b59e67..8c27ff8682 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 @@ def save(self, *args, **kwargs): 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 89c18b7d7d..91051e77a0 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -80,9 +80,9 @@ def setUp(self): 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 @@ def setUp(self): 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 3b7519b7bd..2dad675a49 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -69,13 +69,14 @@ def setUpTestData(cls): # 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 @@ def setUp(self): 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 2872616b8a..b01a197389 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 @@ def post(self, request): 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 6f8ae69c68..2918d2fe3c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -396,7 +396,19 @@

    {{ device }}

    {% 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 11ad5e75d2..2da526a5cc 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 3f0a37b88f..7eabcf5049 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -221,7 +221,19 @@

    {% block title %}{{ virtualmachine }}{% endblock %}

    {% 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 d2bdf983ba..2b7dcd4e4e 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 18f42186f5..d40c9bb293 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 6be0b8a422..074cba9d63 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 79fc053341..7b85c7bf30 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 1e8e9038aa..b10af8c4c4 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -10,8 +10,8 @@ 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 0000000000..63e592c032 --- /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 0000000000..d1db82678e --- /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 6e7cb0bd42..1146fab55e 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -2,6 +2,7 @@ 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 @@ def to_csv(self): 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 d7a64f7db9..e454e7850e 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 3f2ac470de..4f514aab0f 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -428,7 +428,7 @@ def test_delete_vlan_with_prefix(self): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): '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 560313f0ab..91d878a016 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -742,12 +742,12 @@ def setUpTestData(cls): 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 @@ def test_protocol(self): 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 7992e4ddf6..fc595ac9c6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -373,9 +373,9 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): '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 @@ def setUpTestData(cls): 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 b16e99aa3c..f87a7a1a08 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -62,8 +62,8 @@

    {% block title %}{{ service }}{% endblock %}

    {{ 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 521aec137e..48d1b11e30 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 f09f8c8286..1029294765 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.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 @@ def to_csv(self): @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 1146fab55e..f34ed37497 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,7 +14,7 @@ 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 Meta: 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 @@ def to_csv(self): 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 9611be175d..9ece30da5a 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 f87a7a1a08..6a14896812 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -63,7 +63,7 @@

    {% block title %}{{ service }}{% endblock %}

    Ports - {{ service.ports|join:", " }} + {{ service.port_list }} IP Addresses diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index fbb7830e22..52a9515551 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 _curried(*moreargs, **morekwargs): 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 1f0e2607ed..0978ddd8ee 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -843,9 +843,6 @@ def alter_obj(self, obj, request, url_args, url_kwargs): ) 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 9ece30da5a..43f39096f7 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 7b85c7bf30..69453ea6c4 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,7 @@ 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 e454e7850e..532b0bcadd 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 91d878a016..1ecf5a486a 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -763,9 +763,9 @@ def test_protocol(self): 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 f628ca9175..69ddbf7043 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 @@ def __init__(self, *args, **kwargs): 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 d2bdf983ba..b96aa0802e 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 28403f1818..9d65ba8b83 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -4,11 +4,22 @@ 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 bd247070fa..7e1966edbc 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -472,6 +472,13 @@ def _setting(name, default=None): '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 d16cd059d1..34ea7c12f3 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 797149643f..cb970d304f 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 9d65ba8b83..05885f2e81 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -12,7 +12,7 @@ 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 acafd81bda..977b371196 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -291,7 +291,7 @@ def to_internal_value(self, data): ) -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 @@ def __init__(self, *args, **kwargs): # 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 05885f2e81..4b60084c69 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -12,7 +12,10 @@ 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 977b371196..e652656d79 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 @@ def bulk_update(self, request): 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 @@ def __init__(self, *args, **kwargs): # 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 8a62894017..48493d5efa 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 @@ def setUpTestData(cls): 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 286405e546..512d7919cc 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def test_get_rack_elevation_svg(self): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def test_unique_name_per_site_constraint(self): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 f66fea2ace..860aed56fd 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 4f514aab0f..2cc24b6ae9 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def test_delete_vlan_with_prefix(self): 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 f04b2a7cef..7af3c8d790 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -17,6 +17,9 @@ def test_root(self): 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 @@ def setUpTestData(cls): 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 2c6c70fea5..222e3bdce6 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -234,6 +234,7 @@ def test_bulk_create_objects(self): class UpdateObjectViewTestCase(APITestCase): update_data = {} + bulk_update_data = None def test_update_object_without_permission(self): """ @@ -268,6 +269,32 @@ def test_update_object(self): 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 28d4bbb99d..d0e3fccfab 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 @@ def setUpTestData(cls): class VirtualMachineTest(APIViewTestCases.APIViewTestCase): model = VirtualMachine brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'status': 'staged', + } @classmethod def setUpTestData(cls): @@ -196,6 +205,9 @@ def test_unique_name_per_cluster_constraint(self): 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 34ea7c12f3..a3c8143ebe 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 cb970d304f..67665ab380 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 67665ab380..bd54006fbf 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 8741c7f13c..da2097f61a 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,7 +4,7 @@ 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 Meta: 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 2cdd5d2edf..d7cbede697 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 e96293b203..d3be69557b 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 @@ def test_color(self): 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 69ddbf7043..20cb77bdc6 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 @@ def filter(self, qs, value): 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 d3be69557b..4310ee8f08 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -109,12 +109,12 @@ def test_name(self): 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 @@ def test_id(self): 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 d40c9bb293..004ac070c9 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -9,6 +9,7 @@ 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', + 'NestedRouteTargetSerializer', 'NestedServiceSerializer', 'NestedVLANGroupSerializer', 'NestedVLANSerializer', @@ -29,6 +30,18 @@ class Meta: 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 074cba9d63..ffaefad6de 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,20 +3,17 @@ 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 Meta: ] +# +# 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 e297d64512..a8cbf7a29b 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -8,6 +8,9 @@ # 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 dd0731bb8f..69277a8ea1 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -10,7 +10,7 @@ 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 1ad355aec5..e8825ad18e 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -16,6 +16,7 @@ # * 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 69453ea6c4..6059b4330a 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -13,7 +13,7 @@ ) 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 @@ 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', + 'RouteTargetFilterSet', 'ServiceFilterSet', 'VLANFilterSet', 'VLANGroupFilterSet', @@ -50,6 +51,26 @@ class Meta: 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 fd1dd00c63..ed6071756d 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 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 0000000000..d2e800be2d --- /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 c11f0e2965..f743fe5b07 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -107,6 +107,50 @@ def display_name(self): 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 3e89ece648..6a76b5c919 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -5,7 +5,7 @@ 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 Meta(BaseTable.Meta): 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 5333358167..9b0dc581bd 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,7 @@ 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 @@ 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 64e71b69bf..240bfedd3f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -16,7 +16,7 @@ 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 bf3d349cce..12c579bdd9 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 0000000000..d891f92411 --- /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 ffaefad6de..0022dbd736 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -26,14 +26,16 @@ 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 69277a8ea1..449ef32452 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -30,7 +30,9 @@ def get_view_name(self): # 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 6059b4330a..0cbbd3f787 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 ed6071756d..7142798592 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -31,6 +31,14 @@ # 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 Meta: 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 Meta: 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 d2e800be2d..9cc37b742b 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 f743fe5b07..f7e4d9cf47 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 240bfedd3f..bc3e4b69b5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -39,9 +39,20 @@ def get(self, request, pk): 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 d891f92411..2271a8b399 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -83,10 +83,12 @@

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

    {% 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 6fb6d725fb..ef77b5deae 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -99,10 +99,12 @@

    {% block title %}VRF {{ vrf }}{% endblock %}

    {% 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 a2ff51d9b8..41b80bca9b 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 2cc24b6ae9..db98713d03 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -6,7 +6,7 @@ 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 @@ def setUpTestData(cls): 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 1ecf5a486a..aa607eb6bb 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -3,7 +3,7 @@ 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 @@ def setUpTestData(cls): 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 @@ def test_enforce_unique(self): 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 @@ def test_tenant_group(self): 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 fc595ac9c6..db96bb896e 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,7 +4,7 @@ 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 @@ def setUpTestData(cls): } +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 e5ab22f19e..dd6eee77b0 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 0000000000..b71e969046 --- /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 599d05c828..392141fdde 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 bd54006fbf..31437bacb4 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 31437bacb4..ef693a9de6 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 1b5e69cb59..bbf536800e 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 fbe568f18f..408a53c3c7 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 @@ def to_csv(self): ) 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 fa4f817929..d6aee86371 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 3d7963dbb3..463b1a3e33 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 @@ def get_children(self): 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 @@ def to_csv(self): ) 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 1b226586f5..f55d077a44 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 @@ def parent(self): 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 1029294765..409db14c5f 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 @@ def display_name(self): 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 0d33da8067..0adc9aac55 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 @@ def to_csv(self): ) 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 7b4b1665a2..7e1e7a0367 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 c92ff8cdfd..050a6996de 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 f3ff19ddc2..5b7dd16bd8 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 f7e4d9cf47..89ea672c7f 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 @@ def _set_prefix_length(self, value): 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 @@ def _set_mask_length(self, value): 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 @@ def display_name(self): 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 083470bd9c..9c4eb6cd57 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 e81ee1e49b..2c9cd2a9be 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 @@ def to_csv(self): ) 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 4aca8688f9..782b02394e 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,13 +2,9 @@ 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 371eff9db9..4486ecd1bf 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -3,7 +3,8 @@ 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 @@ {{ 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 6a76b5c919..e8b2474ea8 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 f3cc0cb528..0414a01e08 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 d1f17ff1ee..76c37b403a 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -174,6 +174,22 @@ def header(self): 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 5f5b9326de..7cb684dee7 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 Meta(BaseTable.Meta): 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 4486ecd1bf..1053287f7f 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -3,8 +3,8 @@ 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 @@ """ -RACK_DEVICE_COUNT = """ -{{ value }} -""" - -DEVICE_COUNT = """ -{{ value|default:0 }} -""" - RACKRESERVATION_ACTIONS = """ @@ -75,14 +67,6 @@ {% endif %} """ -DEVICEROLE_DEVICE_COUNT = """ -{{ value|default:0 }} -""" - -DEVICEROLE_VM_COUNT = """ -{{ value|default:0 }} -""" - DEVICEROLE_ACTIONS = """ @@ -92,24 +76,12 @@ {% 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 @@ {% 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 Meta(BaseTable.Meta): 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 Meta(BaseTable.Meta): 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 Meta(BaseTable.Meta): 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 e8b2474ea8..1d2ff92438 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -4,7 +4,9 @@ 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 @@ {% 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 Meta(AggregateTable.Meta): 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 7158b0b134..345c8a689e 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 @@ 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 dc96b839c8..8f9073025d 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 76c37b403a..d4861c93a8 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 @@ def __init__(self, *args, **kwargs): 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 7cb684dee7..81cf986d88 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -2,7 +2,9 @@ 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 @@ {{ 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 1053287f7f..7af030a036 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -24,19 +24,6 @@ """ -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 @@ """ -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 7e1e7a0367..45f8ac31f8 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 bec8e2b752..d03dab00a3 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -152,3 +152,6 @@ def to_csv(self): 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 7980bdcc18..8db8f6c57c 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 @@ {% 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 Meta(BaseTable.Meta): 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 1d2ff92438..c6381a37ac 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -78,14 +78,6 @@ {% endif %} """ -STATUS_LABEL = """ -{% if record.pk %} - {{ record.get_status_display }} -{% else %} - Available -{% endif %} -""" - VLAN_LINK = """ {% if record.pk %} {{ record.vid }} @@ -130,12 +122,6 @@ {% 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 Meta(BaseTable.Meta): 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 d4861c93a8..ff19bb4dc2 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 81cf986d88..7acae4cc04 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -7,12 +7,6 @@ ) 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 3acd0d4a1f..23b9b7fd53 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 @@ def get_prep_value(self, value): 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 0000000000..903a6feac5 --- /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 0000000000..4e36c31d07 --- /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 e50fa2eda2..fdd4d1bf57 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -8,6 +8,7 @@ __all__ = ( 'BaseInterface', 'Cable', + 'CablePath', 'CableTermination', 'ConsolePort', 'ConsolePortTemplate', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 2cc4c8b604..12bb224fd9 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 @@ 'FrontPort', 'Interface', 'InventoryItem', + 'PathEndpoint', 'PowerOutlet', 'PowerPort', 'RearPort', @@ -250,12 +252,23 @@ def get_path_endpoints(self): 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 @@ def to_csv(self): # @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 @@ def to_csv(self): # @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 @@ def get_power_draw(self): # @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 Meta: @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 463b1a3e33..162dcb8313 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -14,6 +14,9 @@ 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 @@ __all__ = ( 'Cable', + 'CablePath', 'Device', 'DeviceRole', 'DeviceType', @@ -1154,6 +1158,44 @@ def get_compatible_types(self): 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 172c366b51..4e6cadb283 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 0000000000..2baa916220 --- /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 a317dc937d..706801dd1f 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 0000000000..efd3fcbee5 --- /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 | 201 ++++++++++++++++++++++----- 1 file changed, 167 insertions(+), 34 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index efd3fcbee5..401c38ca57 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -120,91 +120,224 @@ def test_01_interface_to_interface(self): # 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) + 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 2 - cable2 = Cable(termination_a=self.rear_ports[0], termination_b=self.interfaces[1]) - cable2.save() + # 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[1], - path=(cable1, self.front_ports[0], self.rear_ports[0], cable2) + 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=(cable2, self.rear_ports[0], self.front_ports[0], cable1) + 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(), 5) # Two complete + three partial paths + self.assertEqual(CablePath.objects.count(), 6) # Four complete + two partial paths - # Delete cable 1 - cable1.delete() + # 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.rear_ports[0], self.front_ports[0]) + path=(cable2, self.front_ports[1], self.rear_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): + # 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] --C2-- [RP2] [FP2:1] --C3-- [IF2] + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] + [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] """ - # Create cable 1 + # 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.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], + origin=self.interfaces[1], destination=None, - path=(cable1, self.front_ports[0], self.rear_ports[0], cable2, self.rear_ports[1], self.front_ports[4]) + path=(cable2, self.front_ports[1], self.rear_ports[0]) ) - self.assertEqual(CablePath.objects.count(), 1) + self.assertEqual(CablePath.objects.count(), 2) # Create cable 3 - cable3 = Cable(termination_a=self.front_ports[4], termination_b=self.interfaces[1]) + 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=( - cable1, self.front_ports[0], self.rear_ports[0], cable2, self.rear_ports[1], self.front_ports[4], - cable3, + 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=( - cable3, self.front_ports[4], self.rear_ports[1], cable2, self.rear_ports[0], self.front_ports[0], + 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.assertEqual(CablePath.objects.count(), 2) + 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() - # Delete cable 2 - cable2.delete() - self.assertEqual(CablePath.objects.count(), 2) # Two partial paths from IF1 and IF2 + # 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 2baa916220..75029cacc5 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 401c38ca57..767b34594a 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -45,17 +45,36 @@ def setUpTestData(cls): ] 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 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): # 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 767b34594a..3d4efae8e2 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -18,27 +18,7 @@ def setUpTestData(cls): 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 | 22 ++++++++----- 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, 51 insertions(+), 88 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 12bb224fd9..56e8f6fc41 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -252,15 +252,19 @@ def get_path_endpoints(self): 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 - ) +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' + ) + + class Meta: + abstract = True # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ce204bee01..58be5d213f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -32,7 +32,7 @@ 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 @@ def get(self, request, pk): # 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 6fa5e8b912..dc0ff384c9 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 fca1fa5f46..0af64b4c14 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 0000000000..07d73a534f --- /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 706801dd1f..ae1363dbac 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 5800f4b48b..39af6828d4 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 b30fc8456f..4ec1b786eb 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 0000000000..e69de29bb2 diff --git a/netbox/dcim/management/commands/__init__.py b/netbox/dcim/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/netbox/dcim/management/commands/retrace_paths.py b/netbox/dcim/management/commands/retrace_paths.py new file mode 100644 index 0000000000..76c29e89c8 --- /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 408a53c3c7..686ab92191 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,7 +4,7 @@ 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 @@ def termination_z(self): 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 76c29e89c8..b60537b374 100644 --- a/netbox/dcim/management/commands/retrace_paths.py +++ b/netbox/dcim/management/commands/retrace_paths.py @@ -12,6 +12,7 @@ '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 f55d077a44..caa22e74ab 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -10,7 +10,7 @@ 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 @@ def clean(self): @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 b60537b374..a27833d625 100644 --- a/netbox/dcim/management/commands/retrace_paths.py +++ b/netbox/dcim/management/commands/retrace_paths.py @@ -4,7 +4,7 @@ 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 @@ 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) + 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 | 60 ++++++------------- 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, 62 insertions(+), 93 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7256393212..cc8e6df1fd 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 @@ def get_connected_endpoint(self, obj): """ 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 a27833d625..d11a85417e 100644 --- a/netbox/dcim/management/commands/retrace_paths.py +++ b/netbox/dcim/management/commands/retrace_paths.py @@ -5,7 +5,7 @@ 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 @@ def handle(self, *model_names, **options): 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 56e8f6fc41..6bf2ac77af 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 4e6cadb283..46a2cf1d3b 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -6,14 +6,15 @@ 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 3d4efae8e2..68121e6716 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -123,69 +123,43 @@ def test_01_interface_to_interface(self): # 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]) - 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]) + path=(cable1, self.front_ports[16], self.rear_ports[4]) ) - self.assertEqual(CablePath.objects.count(), 2) + self.assertEqual(CablePath.objects.count(), 1) - # Create cable 3 - cable3 = Cable(termination_a=self.rear_ports[0], termination_b=self.interfaces[2]) - cable3.save() + # 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=self.interfaces[2], - path=(cable1, self.front_ports[0], self.rear_ports[0], cable3) + destination=self.interfaces[1], + path=(cable1, self.front_ports[16], self.rear_ports[4], cable2) ) 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) + path=(cable2, self.rear_ports[4], self.front_ports[16], 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 + self.assertEqual(CablePath.objects.count(), 2) - # 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]) - ) - self.assertPathExists( - origin=self.interfaces[1], - destination=None, - path=(cable2, self.front_ports[1], self.rear_ports[0]) + path=(cable1, self.front_ports[16], self.rear_ports[4]) ) - - # 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 75029cacc5..59ca59bfc4 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 58be5d213f..96e6615e8b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1018,7 +1018,7 @@ def get(self, request, pk): # 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 @@ def get(self, request, pk): 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 dc0ff384c9..912404be3f 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 0af64b4c14..b7a5c6b568 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 07d73a534f..1c25a0e284 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 ae1363dbac..1595511924 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 39af6828d4..b3e003e99f 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 4ec1b786eb..c65b685d77 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 cc8e6df1fd..8078f88190 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -30,7 +30,7 @@ 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 @@ def get_connected_endpoint(self, obj): 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 4e36c31d07..2cb8376b7f 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 162dcb8313..3b13b1f739 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -992,6 +992,8 @@ def from_db(cls, db, field_names, values): 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 46a2cf1d3b..0c5da61600 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -5,6 +5,7 @@ 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 59ca59bfc4..f97a1e8f00 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 68121e6716..529935eb3f 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -376,3 +376,43 @@ def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): # 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 3b13b1f739..0cb1ea9704 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -980,6 +980,9 @@ def __init__(self, *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): """ @@ -992,8 +995,6 @@ def from_db(cls, db, field_names, values): 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 0c5da61600..9b0493e345 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 529935eb3f..362b3804b9 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2,6 +2,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 @@ -70,13 +71,14 @@ def setUpTestData(cls): ] 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 @@ def assertPathExists(self, origin, destination, path=None, msg=None): 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 @@ def test_01_interface_to_interface(self): 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 @@ def test_02_interface_to_interface_via_pass_through(self): 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 @@ def test_02_interface_to_interface_via_pass_through(self): 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 @@ def test_02_interface_to_interface_via_pass_through(self): 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): 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 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): 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 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): 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 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): 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 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): 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 @@ def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): 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 @@ def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): 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 @@ def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): 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 @@ def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): 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 @@ def test_06_interface_to_interface_via_existing_cable(self): 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 @@ def test_06_interface_to_interface_via_existing_cable(self): 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 @@ def test_06_interface_to_interface_via_existing_cable(self): 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 f97a1e8f00..16d0753ba9 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 362b3804b9..a851a010b0 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -337,7 +337,81 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): 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 @@ def test_05_interfaces_to_interfaces_via_patched_pass_throughs(self): 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 @@ def test_06_interface_to_interface_via_existing_cable(self): ) 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 c55d099c91..83438a609d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -561,628 +561,3 @@ def test_cable_cannot_terminate_to_a_wireless_interface(self): 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 | 377 ++++++++++++++------------- 1 file changed, 203 insertions(+), 174 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index a851a010b0..53ce82403f 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -19,44 +19,73 @@ def setUpTestData(cls): 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 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 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 four circuits with two terminations (A and Z) each (8 total) provider = Provider.objects.create(name='Provider', slug='provider') @@ -107,17 +136,17 @@ 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 = 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 @@ def test_02_interface_to_interface_via_pass_through(self): [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 @@ def test_02_interface_to_interface_via_pass_through(self): # 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): [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 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): [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 @@ def test_05_interfaces_to_interfaces_via_multiple_pass_throughs(self): [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 @@ def test_06_interfaces_to_interfaces_via_patched_pass_throughs(self): [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 @@ def test_07_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 = 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 @@ def test_08_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 = 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 @@ def test_08_change_cable_status(self): 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 @@ def test_08_change_cable_status(self): 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 | 163 ++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 53ce82403f..18c15fd161 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -8,7 +8,14 @@ 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def assertPathExists(self, origin, destination, path=None, is_connected=None, ms 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 @@ def test_01_interface_to_interface(self): # 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 @@ def test_02_interface_to_interface_via_pass_through(self): ) 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 @@ def test_03_interfaces_to_interfaces_via_pass_through(self): 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 @@ def test_04_interfaces_to_interfaces_via_nested_pass_throughs(self): 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 @@ def test_05_interfaces_to_interfaces_via_multiple_pass_throughs(self): 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 @@ def test_06_interfaces_to_interfaces_via_patched_pass_throughs(self): 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 @@ def test_07_interface_to_interface_via_existing_cable(self): ) 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 512d7919cc..528301f8f4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1453,377 +1453,6 @@ def setUpTestData(cls): ] -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 2cb8376b7f..f3448e7474 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 0cb1ea9704..52627dc7da 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 206dcc3058..ebc0d0ec1a 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 @@ def search(self, queryset, name, value): ).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 457483273d..c76bd3b876 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 @@ def search(self, queryset, name, value): ) -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 @@ def search(self, queryset, name, value): 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 0a2794f01f..c399e1a92c 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1514,9 +1514,8 @@ def test_description(self): 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 @@ def test_description(self): 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 @@ def test_allocated_draw(self): 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 @@ def test_feed_leg(self): 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 @@ def test_name(self): 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 86ea55fa86..d757fd90df 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 @@ 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 0583d4e56d..edbdfb7be3 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -45,7 +45,7 @@ def get_view_name(self): # Mixins -class CableTraceMixin(object): +class PathEndpointMixin(object): @action(detail=True, url_path='trace') def trace(self, request, pk): @@ -57,7 +57,7 @@ def trace(self, request, pk): # 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 @@ def napalm(self, request, pk): # 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 6bf2ac77af..b714c662a6 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 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 @@ def get_cable_peer(self): 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 aa0453bafb..ba58cf67e1 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -207,7 +207,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 16d0753ba9..4ef902dc76 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -2,7 +2,6 @@ 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 96e6615e8b..ac30461b03 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1961,9 +1961,9 @@ def get(self, request, pk): }) -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 @@ def dispatch(self, request, *args, **kwargs): 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 @@ def get(self, request, pk): 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 df484609ac..2f54f94eea 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -51,57 +51,8 @@

    No Cable


    {% 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 ba58cf67e1..90f6b5ef24 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -383,6 +383,7 @@ 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 7af030a036..7ab08eae46 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -812,13 +812,15 @@ class Meta(BaseTable.Meta): # 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 Meta(BaseTable.Meta): 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 0000000000..fabf71798f --- /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 f3448e7474..0d2199e311 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 b714c662a6..65032f5290 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -126,38 +126,30 @@ def get_cable_peer(self): 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 52627dc7da..ea7332b3d0 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1204,6 +1204,13 @@ 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) + # # Virtual chassis diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 9b0493e345..13653c7d43 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 18c15fd161..52b37e3bd6 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -132,6 +132,8 @@ def assertPathExists(self, origin, destination, path=None, is_connected=None, ms :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 @@ def assertPathExists(self, origin, destination, path=None, is_connected=None, ms 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 @@ def test_101_interface_to_interface(self): # 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 @@ def test_101_interface_to_interface(self): # 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 @@ def test_103_consoleport_to_consoleserverport(self): # 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 @@ def test_104_powerport_to_poweroutlet(self): # 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 @@ def test_105_powerport_to_powerfeed(self): # 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 @@ def test_201_single_path_via_pass_through(self): """ [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 @@ def test_201_single_path_via_pass_through(self): # 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 @@ def test_202_multiple_paths_via_pass_through(self): 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 @@ def test_202_multiple_paths_via_pass_through(self): ), is_connected=True ) - self.assertPathExists( + path2 = self.assertPathExists( origin=self.interface2, destination=self.interface4, path=( @@ -395,7 +456,7 @@ def test_202_multiple_paths_via_pass_through(self): ), is_connected=True ) - self.assertPathExists( + path3 = self.assertPathExists( origin=self.interface3, destination=self.interface1, path=( @@ -404,7 +465,7 @@ def test_202_multiple_paths_via_pass_through(self): ), is_connected=True ) - self.assertPathExists( + path4 = self.assertPathExists( origin=self.interface4, destination=self.interface2, path=( @@ -421,12 +482,25 @@ def test_202_multiple_paths_via_pass_through(self): # 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 @@ 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] """ + 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 @@ 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] """ + 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 @@ def test_301_create_path_via_existing_cable(self): """ [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 @@ def test_302_update_path_on_cable_status_change(self): """ [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 7ab08eae46..57cd0b6ebc 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -67,8 +67,8 @@ {% endfor %} """ -CONNECTION_STATUS = """ -{{ record.get_connection_status_display }} +PATH_STATUS = """ +{% if value %}Connected{% else %}Not Connected{% endif %} """ @@ -813,13 +813,13 @@ class Meta(BaseTable.Meta): 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 ac30461b03..87bd383090 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 @@ def queryset_to_csv(self): ] 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): ] 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): ] 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 cd73a614dc..1c8ad69e45 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 0000000000..59647dd2bc --- /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 686ab92191..746a71b3cf 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 e5da5100fd..968c887777 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -131,7 +131,7 @@ def get(self, request, pk): 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 @@ def get(self, request, pk): 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 8078f88190..0cf78fafc6 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 @@ def get_connected_endpoint(self, obj): 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 edbdfb7be3..6ed50324ec 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -470,37 +470,31 @@ def napalm(self, request, pk): # 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 @@ def list(self, request): 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 c76bd3b876..aa5c62552c 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1171,18 +1171,19 @@ class Meta: 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 Meta: 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 Meta: 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 0000000000..f05cfdd1a5 --- /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 65032f5290..72edfc46e2 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -150,6 +150,15 @@ def trace(self): # 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 @@ def to_csv(self): 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 @@ def save(self, *args, **kwargs): 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 caa22e74ab..ec1480a7ef 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 87bd383090..7a1028dece 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 @@ def get(self, request, pk): 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 b2bee0f96a..161bfda743 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 7fcf6ab0a9..12b9918e94 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 59647dd2bc..e5540b44d1 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 746a71b3cf..725fe4b3f8 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -2,7 +2,6 @@ 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 40b03ada6f..159540ece8 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 Meta: 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 0cf78fafc6..b591ae36a0 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 @@ def get_connected_endpoint(self, obj): """ 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 Meta: 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 aa5c62552c..d16f5d1aa4 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 f05cfdd1a5..9404ecf53e 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 72edfc46e2..863b39e626 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 ec1480a7ef..f869a3af43 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 528301f8f4..5c13b51229 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -977,7 +977,7 @@ def test_unique_name_per_site_constraint(self): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): ] -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 @@ def setUpTestData(cls): ] -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 4ef902dc76..40896f97bb 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 7a1028dece..9290a2b4af 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1018,36 +1018,31 @@ def get(self, request, pk): # 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 ef693a9de6..c49283d14f 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 b591ae36a0..144e764dcd 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -52,7 +52,7 @@ def get_connected_endpoint(self, obj): @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 d16f5d1aa4..74b5903a66 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 0d2199e311..2b58f119a0 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 ea7332b3d0..1f9db09866 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 13653c7d43..6079417c1a 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 57cd0b6ebc..1ae5282f34 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 52b37e3bd6..65de412cff 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -123,14 +123,14 @@ def setUpTestData(cls): 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 @@ def assertPathExists(self, origin, destination, path=None, is_connected=None, ms 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 @@ def test_101_interface_to_interface(self): 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 @@ def test_102_consoleport_to_consoleserverport(self): 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 @@ def test_103_powerport_to_poweroutlet(self): 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 @@ def test_104_powerport_to_powerfeed(self): 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 @@ def test_105_interface_to_circuittermination(self): 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 @@ def test_201_single_path_via_pass_through(self): 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 @@ def test_201_single_path_via_pass_through(self): 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 @@ def test_201_single_path_via_pass_through(self): 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 @@ def test_202_multiple_paths_via_pass_through(self): 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 @@ def test_202_multiple_paths_via_pass_through(self): 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 @@ def test_202_multiple_paths_via_pass_through(self): 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 @@ def test_202_multiple_paths_via_pass_through(self): 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 @@ def test_202_multiple_paths_via_pass_through(self): 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 @@ def test_202_multiple_paths_via_pass_through(self): 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 @@ def test_203_multiple_paths_via_nested_pass_throughs(self): 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 @@ def test_203_multiple_paths_via_nested_pass_throughs(self): 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 @@ def test_203_multiple_paths_via_nested_pass_throughs(self): 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 @@ def test_203_multiple_paths_via_nested_pass_throughs(self): 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 @@ def test_204_multiple_paths_via_multiple_pass_throughs(self): 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 @@ def test_204_multiple_paths_via_multiple_pass_throughs(self): 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 @@ def test_204_multiple_paths_via_multiple_pass_throughs(self): 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 @@ def test_204_multiple_paths_via_multiple_pass_throughs(self): 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 @@ def test_205_multiple_paths_via_patched_pass_throughs(self): 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 @@ def test_205_multiple_paths_via_patched_pass_throughs(self): 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 @@ def test_205_multiple_paths_via_patched_pass_throughs(self): 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 @@ def test_205_multiple_paths_via_patched_pass_throughs(self): 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 @@ def test_301_create_path_via_existing_cable(self): 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 @@ def test_301_create_path_via_existing_cable(self): 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 @@ def test_301_create_path_via_existing_cable(self): 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 @@ def test_302_update_path_on_cable_status_change(self): 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 @@ def test_302_update_path_on_cable_status_change(self): 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 @@ def test_302_update_path_on_cable_status_change(self): 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 40896f97bb..186ea72e51 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 9290a2b4af..a2850fc46f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2088,7 +2088,7 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 c49283d14f..b43662a3ea 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 10ae5e5ee6..03c9012af9 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -77,5 +77,5 @@ class Meta: 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 144e764dcd..42018c0466 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -30,7 +30,7 @@ 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 @@ def get_connected_endpoint(self, obj): 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 Meta: 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 Meta: 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 Meta: 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 Meta: 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 Meta: 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 961c458e0e..804e5be037 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -59,12 +59,6 @@ # 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 83d8841df0..f3d9421129 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1714,11 +1714,6 @@ def setUpTestData(cls): '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 1d5c9c0a05..063d300162 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -62,8 +62,8 @@ def field_to_swagger_object(self, field, swagger_object_type, use_references, ** 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 b43662a3ea..ee49f223f7 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 42018c0466..031ce575b8 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -760,7 +760,7 @@ class Meta: 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 Meta: 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 fabf71798f..b416d2e9f4 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 e5540b44d1..0000000000 --- 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 2b58f119a0..dd3c4ed197 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 9404ecf53e..0000000000 --- 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 | 68 +++++++++++------------------------------- netbox/dcim/tables.py | 25 ++++++---------- netbox/dcim/views.py | 6 ++-- 3 files changed, 30 insertions(+), 69 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 74b5903a66..aeccc341bb 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1150,7 +1150,20 @@ def filter_device(self, queryset, name, value): 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 Meta: 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 Meta: 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 Meta: 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 1ae5282f34..b1aa6e57db 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -67,10 +67,6 @@ {% 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 a2850fc46f..d7ddc18557 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2088,7 +2088,7 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 863b39e626..2ab3ce7c85 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -150,6 +150,10 @@ def trace(self): # 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 90f6b5ef24..884941c9dc 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -385,5 +385,6 @@ 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 3f4d7b3069..2b79b2b1cc 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 77d17fe8a7..ae164634de 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 12b9918e94..2a3435f330 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 017a430ee6..9f0a45fcd1 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -123,11 +123,6 @@

    {% block title %}{{ powerfeed }}{% endblock %}

    Cable + {{ instance.cable }} + + + +
    Cable - {{ instance.cable }} - - - -
    Connection StatusPath 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 @@

    {% block title %}{{ powerfeed }}{% endblock %}

    + {% 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 2ea70972b4..82782be09a 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 3f3d288994..52c9c0e954 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 d7ddc18557..c82b9e6ac2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1018,31 +1018,31 @@ def get(self, request, pk): # 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 96b61ea47e..6c7b1c9715 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -479,7 +479,7 @@

    {{ device }}

    -
    +
    {% 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 c82b9e6ac2..3608b37928 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -34,10 +34,11 @@ 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 @@ def dispatch(self, request, *args, **kwargs): 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 2f54f94eea..4b3f7f2d48 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -7,52 +7,70 @@

    {% block title %}Cable Trace for {{ obj }}{% endblock %}

    {% 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 }}

    -
    -
    - {% 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 %} +
    + +
    +
    +

    Near End

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

    Far End

    +
    -
    - {% 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 23b9b7fd53..21af2ed145 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 0000000000..03acc478a3 --- /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 6079417c1a..ee006c9d75 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -7,7 +7,7 @@ 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 65de412cff..cfe63929dd 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -4,7 +4,7 @@ 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 @@ def assertPathExists(self, origin, destination, path=None, is_active=None, msg=N 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 186ea72e51..d36cb1ad32 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 3608b37928..63711a8639 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -38,7 +38,6 @@ 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 @@ def get(self, request, pk): 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 1c25a0e284..3169d2ffc1 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 f267479f38..d362b60034 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 c1e5482d06..ce6edc8837 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 1c8ad69e45..7b147412e1 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 6ed50324ec..a804ad0b68 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -470,31 +470,31 @@ def napalm(self, request, pk): # 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 0000000000..9a470a3c28 --- /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 0000000000..aeb89c5d3e --- /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 2ab3ce7c85..1bd577cdf9 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 Meta: 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 ee006c9d75..5e5915313a 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 83438a609d..01829d7bc7 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -398,9 +398,11 @@ def test_cable_creation(self): 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 @@ def test_cable_deletion(self): 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 6c7b1c9715..d66213a781 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -506,6 +506,7 @@

    {{ device }}

    MTU Mode Cable + Cable Termination Connection @@ -566,7 +567,7 @@

    {{ device }}

    Position Description Cable - Connection + Cable Termination @@ -623,7 +624,7 @@

    {{ device }}

    Positions Description Cable - Connection + Cable Termination @@ -679,6 +680,7 @@

    {{ device }}

    Type Description Cable + Cable Termination Connection @@ -732,6 +734,7 @@

    {{ device }}

    Type Description Cable + Cable Termination Connection @@ -789,6 +792,7 @@

    {{ device }}

    Draw Description Cable + Cable Termination Connection @@ -843,6 +847,7 @@

    {{ device }}

    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 0000000000..e4b28fbcf1 --- /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 912404be3f..ace09cfe2c 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 b7a5c6b568..025b0bf028 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 d362b60034..91374cb1eb 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 1595511924..efaed7ecf4 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 b3e003e99f..38eb7b8a60 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 c65b685d77..c5c18c093c 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 ce6edc8837..fd5ee620c6 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 b1aa6e57db..437daaf296 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -67,6 +67,19 @@ {% 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 Meta(BaseTable.Meta): 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 d66213a781..c06f86bf0c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -792,7 +792,6 @@

    {{ device }}

    Draw Description Cable - Cable Termination Connection @@ -847,7 +846,6 @@

    {{ device }}

    Input/Leg Description Cable - Cable Termination Connection diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 38eb7b8a60..a6a0dd03e4 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 c5c18c093c..125bc54459 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 3cad8b5b30..46b386cf39 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -58,7 +58,7 @@

    {% block title %}{{ powerpanel }}{% endblock %}

    {% block content %}
    -
    +
    Power Panel @@ -82,17 +82,17 @@

    {% block title %}{{ powerpanel }}{% endblock %}

    - {% 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 ee49f223f7..124bbd645d 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 03c9012af9..6bbb0cef52 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -3,7 +3,7 @@ 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 Meta: ] -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 031ce575b8..803b64fbb6 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -27,6 +27,27 @@ 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 Meta: 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 Meta: 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 Meta: 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 Meta: 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 Meta: 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 @@ def validate(self, data): 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 Meta: 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 Meta: 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 Meta: 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 a804ad0b68..14d6177bb4 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -470,31 +470,35 @@ def napalm(self, request, pk): # 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 903a6feac5..0000000000 --- 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 1f9db09866..e6c6134c6c 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -15,7 +15,6 @@ 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 6bbb0cef52..9bc95f065f 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -78,4 +78,5 @@ class Meta: 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 803b64fbb6..11e286132f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -487,7 +487,7 @@ class Meta: 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 Meta: 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 Meta: 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 Meta: 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 Meta: 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 Meta: 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 Meta: 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 1bd577cdf9..6e93dd7687 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -39,6 +39,9 @@ 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 @@ def parent(self): 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 @@ def get_cable_peer(self): 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 e6c6134c6c..e4ea551b9c 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1162,7 +1162,27 @@ def get_compatible_types(self): 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 9756c320b0..b0861a7c00 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -3,7 +3,7 @@ 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 @@ def setUpTestData(cls): )) 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 @@ def test_site(self): 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 63711a8639..696b7f3569 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 @@ def queryset_to_csv(self): 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 124bbd645d..253ed06120 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 d36cb1ad32..ccc849aa56 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -5,12 +5,20 @@ 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 253ed06120..fa3f3aea8f 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 e4ea551b9c..b44146b994 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -7,7 +7,7 @@ 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 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 @@ def save(self, *args, **kwargs): 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 ccc849aa56..52b0a42323 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -4,20 +4,29 @@ 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 696b7f3569..ff4ec1ef6a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1984,15 +1984,11 @@ def get(self, request, pk): 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 | 84 ++++++++++++--------------- netbox/dcim/tests/test_filters.py | 54 +++++++++++++---- 4 files changed, 90 insertions(+), 62 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index ebc0d0ec1a..c573603c64 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 @@ def search(self, queryset, name, value): ).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 b0861a7c00..73701be038 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -316,6 +316,12 @@ def test_site(self): 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 aeccc341bb..9690ee195a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -24,6 +24,7 @@ __all__ = ( 'CableFilterSet', + 'CableTerminationFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', 'ConsolePortTemplateFilterSet', @@ -41,6 +42,7 @@ 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', 'ManufacturerFilterSet', + 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', 'PowerFeedFilterSet', @@ -753,81 +755,76 @@ def search(self, queryset, name, value): ) +class CableTerminationFilterSet(django_filters.FilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) + + class PathEndpointFilterSet(django_filters.FilterSet): - is_connected = django_filters.BooleanFilter( - method='filter_is_connected', - label='Search', + connected = django_filters.BooleanFilter( + method='filter_connected' ) - def filter_is_connected(self, queryset, name, value): - return queryset.filter(_path__is_active=True) + 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, PathEndpointFilterSet): +class ConsolePortFilterSet(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 = 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 @@ def filter_kind(self, queryset, name, value): }.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 @@ def search(self, queryset, name, value): 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 c399e1a92c..f209cd1f48 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1514,9 +1514,11 @@ def test_description(self): 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 @@ def test_description(self): 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 @@ def test_allocated_draw(self): 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 @@ def test_feed_leg(self): 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 @@ def test_name(self): 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 @@ def setUpTestData(cls): ) 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 @@ def test_rack_id(self): 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 9a470a3c28..630c3b4ece 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 b416d2e9f4..4a5b26efa6 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 @@ 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 aeb89c5d3e..c45d033968 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 dd3c4ed197..737e59b326 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 d11a85417e..0000000000 --- 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 0000000000..47636a943e --- /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 66ba7b39f1..468f189b33 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 | 28 ++++++++++++++----------- netbox/templates/dcim/rack.html | 12 +++++++---- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 6e93dd7687..72bd453c5b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -320,8 +320,12 @@ def get_power_draw(self): """ # 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 @@ def get_power_draw(self): } # 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 409db14c5f..78c72f5038 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -3,6 +3,7 @@ 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.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 @@ def get_power_utilization(self): """ 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 + + 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 - 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 + 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 4cf3b9018f..c44b75be1b 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -150,10 +150,14 @@

    {% block title %}Rack {{ rack }}{% endblock %}

    {{ 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 6073c06ee1..71b7c56ef3 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 4b3f7f2d48..7df39caca1 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -7,66 +7,65 @@

    {% block title %}Cable Trace for {{ obj }}{% endblock %}

    {% 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 71b7c56ef3..0000000000 --- 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 0000000000..2cb5ffed64 --- /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 0000000000..ef1ed05bc5 --- /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 0000000000..c3ed109d70 --- /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 0000000000..a439aff27a --- /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 0000000000..dedb562ea7 --- /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 11e286132f..d599e7461b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,12 +7,13 @@ 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 Meta: ] +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 14d6177bb4..50dc82c9d8 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -17,7 +17,7 @@ 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 @@ def trace(self, request, pk): 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 d599e7461b..f9e17b12cb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -786,17 +786,23 @@ def get_path(self, obj): 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 9bc95f065f..ad5e609e44 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -77,6 +77,6 @@ class Meta: 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 f9e17b12cb..d6da5a5e32 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 Meta: 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 Meta: 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 fa3f3aea8f..33c3bd1b6f 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 18e42318b9..e788c9b5fb 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 72bd453c5b..2ea9b88b6b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -172,9 +172,11 @@ def trace(self): 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 cfe63929dd..95a8e0d30c 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -721,6 +721,63 @@ def test_205_multiple_paths_via_patched_pass_throughs(self): 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 52b0a42323..b82dd58d2b 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 7df39caca1..a328f20520 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -19,16 +19,17 @@

    {% block title %}Cable Trace for {{ obj }}{% endblock %}

    {% 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 @@

    No cable

    {% 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 33c3bd1b6f..0e636fc45d 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 7b147412e1..5168319832 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,6 +3,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 . 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 50dc82c9d8..c45879dbe2 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 c45879dbe2..b14c67e652 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -58,6 +58,9 @@ def trace(self, request, pk): 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 | 704 ++++++++++++++------------- 1 file changed, 365 insertions(+), 339 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 95a8e0d30c..5699b3b886 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') - - # 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') - 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 + cls.device = Device.objects.create(site=cls.site, device_type=device_type, device_role=device_role, name='Test Device') + + cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel') + 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 @@ def test_101_interface_to_interface(self): """ [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 @@ def test_102_consoleport_to_consoleserverport(self): """ [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 @@ def test_103_powerport_to_poweroutlet(self): """ [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 @@ def test_104_powerport_to_powerfeed(self): """ [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 @@ def test_104_powerport_to_powerfeed(self): 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 @@ def test_105_interface_to_circuittermination(self): 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 @@ def test_201_single_path_via_pass_through(self): # 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 @@ def test_202_multiple_paths_via_pass_through(self): # 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 @@ 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] """ - 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 @@ def test_204_multiple_paths_via_multiple_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] + [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 @@ 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() + 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 @@ def test_206_unidirectional_split_paths(self): # 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 @@ def test_302_update_path_on_cable_status_change(self): 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 @@ def test_302_update_path_on_cable_status_change(self): 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 e788c9b5fb..0000000000 --- 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 db34caefb7..353c9d3567 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -422,14 +422,15 @@ def _setting(name, default=None): '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 0e636fc45d..26b13477ae 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 0000000000..ea91906235 --- /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 725fe4b3f8..a4eed83f0d 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 48493d5efa..26ec5928b3 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -153,10 +153,10 @@ def setUpTestData(cls): 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 73701be038..9477bfbac0 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -50,8 +50,8 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 5699b3b886..9e0f0e77fc 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -234,9 +234,7 @@ def test_105_interface_to_circuittermination(self): [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 01829d7bc7..85fa44be53 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -390,8 +390,8 @@ def setUp(self): 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 8a6171e5fd..ad41e7d60a 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -47,7 +47,7 @@

    {% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form
    Termination Details
    - +
    {{ form.port_speed }} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index abbb79a146..511ab19771 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 89ea672c7f..11ce180a43 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -30,6 +30,7 @@ '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 26b13477ae..69df8c15d3 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 c06d4b32d0..0071791fa2 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -339,3 +339,20 @@ def get_changed_object(self, obj): 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 20d0f8d171..d5d7e15b67 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -29,5 +29,8 @@ # 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 940cc7912a..96dcb4d8ff 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 ad5884b7af..91f4d333ba 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -13,6 +13,7 @@ __all__ = ( 'ConfigContextFilterSet', + 'ContentTypeFilterSet', 'CreatedUpdatedFilterSet', 'CustomFieldFilter', 'CustomFieldFilterSet', @@ -313,3 +314,14 @@ def search(self, queryset, name, value): 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 7e75879018..d09ff64fe4 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 860aed56fd..66acb07417 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -2,6 +2,7 @@ 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 @@ def test_get_rack_last_updated_lte(self): 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 96dcb4d8ff..75a501862a 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 | 505 +----------------- 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, 750 insertions(+), 747 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 0768832821..2d3457d2c7 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 ad5e609e44..88890bf952 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -6,8 +6,8 @@ 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 0bcb2d280b..b496796fe3 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 5168319832..f64dbc2dd6 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -5,7 +5,7 @@ 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 159540ece8..d63d32d68a 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 d6da5a5e32..6008188bbb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -18,12 +18,13 @@ from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN -from tenancy.api.nested_serializers import NestedTenantSerializer -from users.api.nested_serializers import NestedUserSerializer -from utilities.api import ( +from netbox.api import ( ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, - WritableNestedSerializer, get_serializer_for_model, + WritableNestedSerializer, ) +from tenancy.api.nested_serializers import NestedTenantSerializer +from users.api.nested_serializers import NestedUserSerializer +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 e8c4fbe1d3..689cb7aa10 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 b14c67e652..08105460f9 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -25,11 +25,12 @@ ) 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 52c9a18ab0..a5f11fde6f 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -6,7 +6,7 @@ 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 95c9807689..762bfb0d9e 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 0071791fa2..268a5d7c0f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,13 +13,12 @@ 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 d5d7e15b67..917aedca58 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 75a501862a..dc0c6ad7c5 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -15,9 +15,10 @@ 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 004ac070c9..660db2b22d 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 ed8680a723..31f708c86e 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -11,10 +11,9 @@ 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 a8cbf7a29b..13a1bc7700 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 449ef32452..0d50b6ea7a 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -11,7 +11,7 @@ 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 4b60084c69..0000000000 --- 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 0000000000..afb2a68030 --- /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 0000000000..1cb32c1e42 --- /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 0000000000..8c62eee4c6 --- /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 0000000000..ca66d97d48 --- /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 8fd664d5ac..1d0397e4d6 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 0000000000..d489ce9510 --- /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 0000000000..c492510fb6 --- /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 0000000000..71df1796e8 --- /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 0000000000..c5ecf93722 --- /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 0000000000..3dd512205d --- /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 353c9d3567..ed64a71a09 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -461,18 +461,18 @@ def _setting(name, default=None): '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 @@ def _setting(name, default=None): # 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 13c016c18e..aaec27c1f0 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 1fd3f19ef9..b08b87bc51 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -6,7 +6,8 @@ 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 5ad05b09ea..4000177b23 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 33cddea2bd..940d23a0bf 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -9,10 +9,10 @@ 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 369d5eb1bc..7b227c1232 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 4467b050bd..05e83853ee 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,8 +2,8 @@ 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 ad4424005a..32540879d7 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 065d3a9f32..34be4991e2 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,9 +4,9 @@ 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 f1bcf3b37b..3b43ca7c99 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -2,8 +2,8 @@ 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 1f338d6e4a..31e7915b8c 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,8 +2,8 @@ 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 c52c6c87fb..4176cc806b 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 a3536e960d..b799bee19d 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -2,9 +2,9 @@ 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 e652656d79..958cdf9a38 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 .utils import dict_to_filter_params, dynamic_import - -HTTP_ACTIONS = { - 'GET': 'view', - 'OPTIONS': None, - 'HEAD': 'view', - 'POST': 'add', - 'PUT': 'change', - 'PATCH': 'change', - 'DELETE': 'delete', -} - +from rest_framework.utils import formatting -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): - """ - 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 - - -# -# 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: +def get_view_name(view, suffix=None): """ - 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" - } - ] + Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. """ - 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) + 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 - # 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', - }) + 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) - 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) + if suffix: + name += ' ' + suffix - return self.APIRootView.as_view(api_root_dict=api_root_dict) + return name diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 063d300162..1b931155fc 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -6,7 +6,7 @@ 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 de56e6e6a5..7763f0ef41 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 711e1359e2..3e977d94d7 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -7,8 +7,8 @@ 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 c40202a7dd..d9df2fcfe0 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 9d210459e5..a8462ff1da 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -3,7 +3,7 @@ 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 69df8c15d3..d1564e5e1d 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 3dd512205d..887d6b5f83 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 @@ def perform_destroy(self, instance): 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 0ee2d78dc1..2ea12e72f6 100644 --- a/netbox/netbox/tests/test_api.py +++ b/netbox/netbox/tests/test_api.py @@ -6,8 +6,13 @@ 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 4878729b06..250b1ac67e 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -6,6 +6,7 @@ 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 @@ 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 161bfda743..baf014dbf7 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -342,6 +342,7 @@ def get(self, request, format=None): ('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 887d6b5f83..86910089a4 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -252,6 +252,7 @@ def get(self, request): # 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 a8b4c0eb8c..1b2fe3d129 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -79,7 +79,7 @@ class Meta: 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 a5f11fde6f..d17090fc2e 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -24,7 +24,7 @@ def __call__(self, serializer_field): # 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 @@ def _get_custom_fields(self): """ 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 @@ def __init__(self, *args, **kwargs): # 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 91f4d333ba..d36ab28ad7 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 d09ff64fe4..eee54076b9 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -35,7 +35,7 @@ def _append_customfield_fields(self): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 1ae63a2c35..923b5dbc4b 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 ac6d3816f3..e4bcef1b3f 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 73fc6a2e36..62912ee2cd 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -50,11 +50,11 @@ def get_for_model(self, model): 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 8c88bb1da5..065f517f5e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,6 @@ 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 d4e187b5c5..0d6295e5b2 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 5d1dee864b..dbdbb53439 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -25,7 +25,7 @@ def setUpTestData(cls): 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 @@ def setUp(self): 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 38d904d412..7ebb7701ec 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -39,7 +39,7 @@ def test_simple_fields(self): # 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 @@ def test_select_field(self): 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 @@ def setUp(self): 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 @@ def setUpTestData(cls): # 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 @@ def setUpTestData(cls): ) 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 01d4ab8f3d..2cc9accaa5 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -131,7 +131,7 @@ def setUp(self): 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 fdd4d1bf57..513c074388 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 0000000000..891230cb01 --- /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 b44146b994..98e4045f1f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -2,32 +2,26 @@ 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 @@ def get_cables(self, pk_list=False): """ 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 @@ def get_status_class(self): 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 d1564e5e1d..f8288cd6a8 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 1b2fe3d129..e1ffc044af 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 d17090fc2e..8cb7dd5bae 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 @@ def to_internal_value(self, data): # 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 923b5dbc4b..7d0a4c5754 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 62912ee2cd..5c403128c8 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -4,10 +4,12 @@ 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 @@ def remove_stale_data(self, content_types): 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 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= # 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 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import= # 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 7ebb7701ec..ca33c4697c 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -21,7 +21,6 @@ def setUp(self): ]) 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 @@ def test_simple_fields(self): 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 @@ def test_simple_fields(self): cf.delete() def test_select_field(self): - obj_type = ContentType.objects.get_for_model(Site) # Create a custom field @@ -73,7 +70,6 @@ def test_select_field(self): ) 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 @@ def test_update_single_object_with_values(self): 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 517a567a93..b087b08671 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 f8288cd6a8..e5734ea8e2 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 0000000000..8d0ae48e30 --- /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 a4eed83f0d..3d6d5d2328 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 0000000000..6c805f2ee6 --- /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 98e4045f1f..2c067bd866 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 f869a3af43..8dae9074aa 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 78c72f5038..b5dd920695 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 0adc9aac55..923b33124f 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 0000000000..09bfdda6e6 --- /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 11ce180a43..f6414d5d7f 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 0000000000..e41d817610 --- /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 f5508d47d1..862fd4f2d6 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 0000000000..1e29a0f5e7 --- /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 5a6108e091..3ba644c096 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 0000000000..d6820640d0 --- /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 2c9cd2a9be..198bfd1a5a 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 31f708c86e..7552ae0d21 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 0cbbd3f787..988ee86fb5 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -122,7 +122,7 @@ class Meta: 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 7142798592..ea70141210 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 Meta: } -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 0000000000..a5ec9013b0 --- /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 f6414d5d7f..493df9d9a7 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 @@ def to_csv(self): 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 c6381a37ac..cb1d22a39e 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 aa607eb6bb..f86e3f8b6d 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -240,13 +240,28 @@ def setUpTestData(cls): ) 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 @@ def test_rir(self): 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 3cb83ab543..afd20bb268 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 e5734ea8e2..4be51ca285 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 4be51ca285..5766295af3 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 437daaf296..0000000000 --- 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 0000000000..e68779e3d3 --- /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 0000000000..cdb79f4e12 --- /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 0000000000..c614c534b6 --- /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 0000000000..b65289fcf5 --- /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 0000000000..8d90fb620a --- /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 0000000000..75a5c85eac --- /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 0000000000..76f30f507c --- /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 0000000000..b3840b48bb --- /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 7acae4cc04..956c207842 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 804e5be037..0fc69be3bb 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -77,12 +77,13 @@ ) 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 c614c534b6..334947263c 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -10,7 +10,7 @@ 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 Meta(BaseTable.Meta): 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 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', + '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 8d90fb620a..471b1bb3ad 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -3,6 +3,7 @@ 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 Meta(BaseTable.Meta): # 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 Meta(BaseTable.Meta): 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 b3840b48bb..7278de113f 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 334947263c..21c2f4244a 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 Meta(DeviceComponentTable.Meta): 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 Meta(DeviceComponentTable.Meta): 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 Meta(DeviceComponentTable.Meta): 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 471b1bb3ad..ae5c2a5c81 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -42,6 +42,8 @@ class Meta(BaseTable.Meta): # 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 21c2f4244a..5f61e03ba1 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -10,11 +10,14 @@ 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 Meta(DeviceComponentTable.Meta): 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 7278de113f..e17a5a8772 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -73,3 +73,24 @@ {% 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 06fb7619f2..3cf011503a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1019,6 +1019,9 @@ def get(self, request, pk): 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 @@ def get(self, request, pk): 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 c06f86bf0c..1a74016b29 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -131,7 +131,7 @@

    {{ device }}

    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 @@

    {{ device }}

    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 ace09cfe2c..0000000000 --- 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 5f61e03ba1..84460a6326 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -11,13 +11,15 @@ 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 Meta(DeviceComponentTable.Meta): 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 e17a5a8772..5e2b7e1c6d 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -94,3 +94,20 @@ {% 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 3cf011503a..0de6804204 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1029,6 +1029,10 @@ def get(self, request, pk): ).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 @@ def get(self, request, pk): 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 1a74016b29..962d1127f6 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -134,7 +134,7 @@

    {{ device }}

    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 @@

    {{ device }}

    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 025b0bf028..0000000000 --- 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 84460a6326..92d2be1381 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -12,18 +12,19 @@ ) 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 Meta(DeviceComponentTable.Meta): 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 5e2b7e1c6d..121b33bd32 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -111,3 +111,19 @@ {% 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 0de6804204..aa00ad1ffc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1038,6 +1038,9 @@ def get(self, request, pk): 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 @@ def get(self, request, pk): '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 962d1127f6..d9c1e55b8d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -137,7 +137,7 @@

    {{ device }}

    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 @@

    {{ device }}

    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 @@

    {{ device }}

    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 @@

    {{ device }}

    Add interfaces
  • -
    {% endif %} +
    @@ -555,28 +555,7 @@

    {{ device }}

    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 @@

    {{ device }}

    Add rear ports -
    {% endif %} +
    @@ -804,8 +783,8 @@

    {{ device }}

    Add power outlets -
    {% endif %} +
    @@ -857,8 +836,8 @@

    {{ device }}

    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 91374cb1eb..0000000000 --- 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 95e459a475..d77b2cc888 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -12,7 +12,7 @@ ) 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 @@ 'DeviceImportTable', 'DevicePowerPortTable', 'DevicePowerOutletTable', + 'DeviceRearPortTable', 'DeviceRoleTable', 'DeviceTable', 'FrontPortTable', @@ -458,6 +459,31 @@ class Meta(DeviceComponentTable.Meta): 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 e7182e1da5..9d717f794d 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -157,3 +157,21 @@ {% 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 1056a8078e..c09b6cca5b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1067,6 +1067,9 @@ def get(self, request, pk): # 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 @@ def get(self, request, pk): '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 94f66b3458..693f3ccf83 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -128,7 +128,7 @@

    {{ device }}

    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 @@

    {{ device }}

    {% 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 @@

    {{ device }}

    - - - - {% 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 }}

    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 @@

    {{ device }}

    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 f7309fa59e..0000000000 --- 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 ac8ecca76d..6041747018 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -80,6 +80,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ {% 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 @@ 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 e318babb09..185ad9b32e 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -11,9 +11,9 @@ 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 Meta(DeviceComponentTable.Meta): 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 Meta(DeviceComponentTable.Meta): 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 6041747018..fcfeb01471 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -28,6 +28,16 @@ """ +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 24a8284f12..5fd3a3a79d 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 df8ac064b7..bf205b92a1 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 d7b35d3d76..7253bdcae0 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -485,11 +485,6 @@

    {{ device }}

    Interfaces -
    - -
    diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 7eabcf5049..b72eec571b 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -276,11 +276,6 @@

    {% block title %}{{ virtualmachine }}{% endblock %}

    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 93efafb5ab..0000000000 --- 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 b72eec571b..a2213aee1f 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -280,30 +280,7 @@

    {% block title %}{{ virtualmachine }}{% endblock %}

    - - - - {% 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 %}