From d0bd1ad25b168555725b1f53b4bace0a09cbe413 Mon Sep 17 00:00:00 2001 From: dansheps Date: Mon, 24 Feb 2020 10:18:19 -0600 Subject: [PATCH 01/66] Fixes: #4255 - Add new script variable types based on dynamic model fields --- netbox/extras/scripts.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 57cbc8149..68a1a5ba4 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -18,6 +18,7 @@ from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING from utilities.exceptions import AbortTransaction +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField from .forms import ScriptForm from .signals import purge_changelog @@ -197,6 +198,20 @@ class MultiObjectVar(ScriptVariable): self.form_field = TreeNodeMultipleChoiceField +class DynamicObjectVar(ObjectVar): + """ + A dynamic netbox object variable. APISelect will determine the available choices + """ + form_field = DynamicModelChoiceField + + +class DynamicMultiObjectVar(MultiObjectVar): + """ + A multiple choice version of DynamicObjectVar + """ + form_field = DynamicModelMultipleChoiceField + + class FileVar(ScriptVariable): """ An uploaded file. From a5853427d44aa56abb93c74401753a787d59a467 Mon Sep 17 00:00:00 2001 From: dansheps Date: Mon, 24 Feb 2020 10:21:17 -0600 Subject: [PATCH 02/66] Update __all__ for #4255 --- netbox/extras/scripts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 68a1a5ba4..eb796cf32 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -26,6 +26,8 @@ __all__ = [ 'BaseScript', 'BooleanVar', 'ChoiceVar', + 'DynamicObjectVar', + 'DynamicMultiObjectVar', 'FileVar', 'IntegerVar', 'IPAddressVar', From 8ed0d0400f5dcd5c784fac1fb41b1ec2a0245faf Mon Sep 17 00:00:00 2001 From: dansheps Date: Mon, 24 Feb 2020 10:29:07 -0600 Subject: [PATCH 03/66] Add tests --- netbox/extras/tests/test_scripts.py | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 6237d1d95..c926a0b29 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -145,6 +145,30 @@ class ScriptVariablesTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data['var1'].pk, data['var1']) + def test_dynamicobjectvar(self): + """ + Test dynamic version of the objectvar + """ + + class TestScript(Script): + + var1 = DynamicObjectVar( + queryset=DeviceRole.objects.all() + ) + + # Populate some objects + for i in range(1, 6): + DeviceRole( + name='Device Role {}'.format(i), + slug='device-role-{}'.format(i) + ).save() + + # Validate valid data + data = {'var1': DeviceRole.objects.first().pk} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'].pk, data['var1']) + def test_multiobjectvar(self): class TestScript(Script): @@ -168,6 +192,32 @@ class ScriptVariablesTest(TestCase): self.assertEqual(form.cleaned_data['var1'][1].pk, data['var1'][1]) self.assertEqual(form.cleaned_data['var1'][2].pk, data['var1'][2]) + def test_dynamicmultiobjectvar(self): + """ + Test dynamic version of the multiobjectvar + """ + + class TestScript(Script): + + var1 = DynamicMultiObjectVar( + queryset=DeviceRole.objects.all() + ) + + # Populate some objects + for i in range(1, 6): + DeviceRole( + name='Device Role {}'.format(i), + slug='device-role-{}'.format(i) + ).save() + + # Validate valid data + data = {'var1': [role.pk for role in DeviceRole.objects.all()[:3]]} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'][0].pk, data['var1'][0]) + self.assertEqual(form.cleaned_data['var1'][1].pk, data['var1'][1]) + self.assertEqual(form.cleaned_data['var1'][2].pk, data['var1'][2]) + def test_filevar(self): class TestScript(Script): From 27e3b6f377afb8c88e2e79ca805c335d15cff11a Mon Sep 17 00:00:00 2001 From: dansheps Date: Thu, 27 Feb 2020 07:45:11 -0600 Subject: [PATCH 04/66] Remove second variables, make widget mandatory on ObjectVar and MultiObjectVar --- netbox/extras/scripts.py | 28 +++----------- netbox/extras/tests/test_scripts.py | 57 +++-------------------------- 2 files changed, 11 insertions(+), 74 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index eb796cf32..fdde58a83 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -26,8 +26,6 @@ __all__ = [ 'BaseScript', 'BooleanVar', 'ChoiceVar', - 'DynamicObjectVar', - 'DynamicMultiObjectVar', 'FileVar', 'IntegerVar', 'IPAddressVar', @@ -170,10 +168,10 @@ class ObjectVar(ScriptVariable): """ NetBox object representation. The provided QuerySet will determine the choices available. """ - form_field = forms.ModelChoiceField + form_field = DynamicModelChoiceField - def __init__(self, queryset, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, queryset, widget, *args, **kwargs): + super().__init__(widget=widget, *args, **kwargs) # Queryset for field choices self.field_attrs['queryset'] = queryset @@ -187,10 +185,10 @@ class MultiObjectVar(ScriptVariable): """ Like ObjectVar, but can represent one or more objects. """ - form_field = forms.ModelMultipleChoiceField + form_field = DynamicModelMultipleChoiceField - def __init__(self, queryset, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, queryset, widget, *args, **kwargs): + super().__init__(widget=widget, *args, **kwargs) # Queryset for field choices self.field_attrs['queryset'] = queryset @@ -200,20 +198,6 @@ class MultiObjectVar(ScriptVariable): self.form_field = TreeNodeMultipleChoiceField -class DynamicObjectVar(ObjectVar): - """ - A dynamic netbox object variable. APISelect will determine the available choices - """ - form_field = DynamicModelChoiceField - - -class DynamicMultiObjectVar(MultiObjectVar): - """ - A multiple choice version of DynamicObjectVar - """ - form_field = DynamicModelMultipleChoiceField - - class FileVar(ScriptVariable): """ An uploaded file. diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index c926a0b29..2d4d5b3fd 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -4,6 +4,7 @@ from netaddr import IPAddress, IPNetwork from dcim.models import DeviceRole from extras.scripts import * +from utilities.forms import APISelect, APISelectMultiple class ScriptVariablesTest(TestCase): @@ -129,31 +130,8 @@ class ScriptVariablesTest(TestCase): class TestScript(Script): var1 = ObjectVar( - queryset=DeviceRole.objects.all() - ) - - # Populate some objects - for i in range(1, 6): - DeviceRole( - name='Device Role {}'.format(i), - slug='device-role-{}'.format(i) - ).save() - - # Validate valid data - data = {'var1': DeviceRole.objects.first().pk} - form = TestScript().as_form(data, None) - self.assertTrue(form.is_valid()) - self.assertEqual(form.cleaned_data['var1'].pk, data['var1']) - - def test_dynamicobjectvar(self): - """ - Test dynamic version of the objectvar - """ - - class TestScript(Script): - - var1 = DynamicObjectVar( - queryset=DeviceRole.objects.all() + queryset=DeviceRole.objects.all(), + widget=APISelect(api_url='/api/dcim/device-roles/') ) # Populate some objects @@ -174,33 +152,8 @@ class ScriptVariablesTest(TestCase): class TestScript(Script): var1 = MultiObjectVar( - queryset=DeviceRole.objects.all() - ) - - # Populate some objects - for i in range(1, 6): - DeviceRole( - name='Device Role {}'.format(i), - slug='device-role-{}'.format(i) - ).save() - - # Validate valid data - data = {'var1': [role.pk for role in DeviceRole.objects.all()[:3]]} - form = TestScript().as_form(data, None) - self.assertTrue(form.is_valid()) - self.assertEqual(form.cleaned_data['var1'][0].pk, data['var1'][0]) - self.assertEqual(form.cleaned_data['var1'][1].pk, data['var1'][1]) - self.assertEqual(form.cleaned_data['var1'][2].pk, data['var1'][2]) - - def test_dynamicmultiobjectvar(self): - """ - Test dynamic version of the multiobjectvar - """ - - class TestScript(Script): - - var1 = DynamicMultiObjectVar( - queryset=DeviceRole.objects.all() + queryset=DeviceRole.objects.all(), + widget=APISelectMultiple(api_url='/api/dcim/device-roles/') ) # Populate some objects From 2fcdc90d3f8addfa05e873b2758560c3b92b9792 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 24 Jan 2020 00:15:32 +0100 Subject: [PATCH 05/66] Automatically check for new versions --- docs/configuration/optional-settings.md | 16 +++++++++++++++ netbox/netbox/configuration.example.py | 8 ++++++++ netbox/netbox/settings.py | 10 +++++++++ netbox/templates/_base.html | 11 +++++++++- netbox/utilities/context_processors.py | 23 +++++++++++++++++++++ netbox/utilities/versions.py | 27 +++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 netbox/utilities/versions.py diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index cbe01728c..36687343a 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -157,6 +157,22 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni --- +## GITHUB_REPOSITORY + +Default: 'netbox-community/netbox' + +The tags of this repository are checked to detect new releases, which are shown in the footer of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. + +--- + +## GITHUB_VERSION_TIMEOUT + +Default: 8 * 3600 + +The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. Set to 0 to disable the version check. + +--- + ## LOGGING By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`. diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 7002def9b..63521789a 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -124,6 +124,14 @@ EXEMPT_VIEW_PERMISSIONS = [ # 'ipam.prefix', ] +# This repository is used to check whether there is a new release of NetBox available. Set to None to disable the +# version check. +GITHUB_REPOSITORY = 'netbox-community/netbox' + +# This determines how often the GitHub API is called to check the latest release of NetBox. Set to 0 to disable the +# version check. +GITHUB_VERSION_TIMEOUT = 8 * 3600 + # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs: # https://docs.djangoproject.com/en/stable/topics/logging/ LOGGING = {} diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 89958bc13..1e598f85d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,6 +1,7 @@ import logging import os import platform +import re import socket import warnings @@ -78,6 +79,8 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False) EMAIL = getattr(configuration, 'EMAIL', {}) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) +GITHUB_REPOSITORY = getattr(configuration, 'GITHUB_REPOSITORY', 'netbox-community/netbox') +GITHUB_VERSION_TIMEOUT = getattr(configuration, 'GITHUB_VERSION_TIMEOUT', 8 * 3600) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) @@ -292,6 +295,7 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'utilities.context_processors.settings', + 'utilities.context_processors.latest_version', ], }, }, @@ -302,6 +306,12 @@ AUTHENTICATION_BACKENDS = [ 'utilities.auth_backends.ViewExemptModelBackend', ] +# GitHub repository for version check +if GITHUB_REPOSITORY and not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', GITHUB_REPOSITORY): + raise ImproperlyConfigured( + "GITHUB_REPOSITORY must contain the name of a GitHub repository in the form '/'" + ) + # Internationalization LANGUAGE_CODE = 'en-us' USE_I18N = True diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 1b7a9da80..cfc2a9cc8 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -50,7 +50,16 @@
-

{{ settings.HOSTNAME }} (v{{ settings.VERSION }})

+

+ {{ settings.HOSTNAME }} (v{{ settings.VERSION }}) + {% if latest_version %} + {% if latest_version_url %}{% endif %} + + New version: {{ latest_version }} + + {% if latest_version_url %}{% endif %} + {% endif %} +

{% now 'Y-m-d H:i:s T' %}

diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index 06c5c8784..b12a127fa 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -1,4 +1,7 @@ from django.conf import settings as django_settings +from packaging import version + +from utilities.versions import get_latest_version def settings(request): @@ -8,3 +11,23 @@ def settings(request): return { 'settings': django_settings, } + + +def latest_version(request): + """ + Get the latest version from the GitHub repository + """ + github_latest_version, github_url = get_latest_version() + + latest_version_str = None + latest_version_url = None + if isinstance(github_latest_version, version.Version): + current_version = version.parse(django_settings.VERSION) + if github_latest_version > current_version: + latest_version_str = str(github_latest_version) + latest_version_url = github_url + + return { + 'latest_version': latest_version_str, + 'latest_version_url': latest_version_url + } diff --git a/netbox/utilities/versions.py b/netbox/utilities/versions.py new file mode 100644 index 000000000..bebe26fba --- /dev/null +++ b/netbox/utilities/versions.py @@ -0,0 +1,27 @@ +import requests +from cacheops import cached +from django.conf import settings +from packaging import version + +if settings.GITHUB_VERSION_TIMEOUT and settings.GITHUB_REPOSITORY: + @cached(timeout=settings.GITHUB_VERSION_TIMEOUT) + def get_latest_version(): + url = 'https://api.github.com/repos/{}/releases'.format(settings.GITHUB_REPOSITORY) + headers = { + 'Accept': 'application/vnd.github.v3+json', + } + try: + response = requests.get(url, headers=headers) + versions = [(version.parse(release['tag_name']), release.get('html_url')) + for release in response.json() + if 'tag_name' in release] + if versions: + return max(versions) + except: + pass + + return 'unknown', None + +else: + def get_latest_version(): + return None From 405d93c6f272047c568f984941e09cbfb34fe1f3 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 24 Jan 2020 10:11:32 +0100 Subject: [PATCH 06/66] Update versions.py --- netbox/utilities/versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/versions.py b/netbox/utilities/versions.py index bebe26fba..40d5d90af 100644 --- a/netbox/utilities/versions.py +++ b/netbox/utilities/versions.py @@ -17,7 +17,7 @@ if settings.GITHUB_VERSION_TIMEOUT and settings.GITHUB_REPOSITORY: if 'tag_name' in release] if versions: return max(versions) - except: + except Exception: pass return 'unknown', None From 9d66ac4a6a8cb6e65ba21549c50a71bc66881ae4 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 24 Jan 2020 18:06:10 +0100 Subject: [PATCH 07/66] Refactor the code to be more readable --- netbox/utilities/context_processors.py | 10 +++--- netbox/utilities/versions.py | 48 +++++++++++++++----------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index b12a127fa..5dadf55da 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -1,7 +1,7 @@ from django.conf import settings as django_settings from packaging import version -from utilities.versions import get_latest_version +from utilities.versions import get_latest_release def settings(request): @@ -17,14 +17,14 @@ def latest_version(request): """ Get the latest version from the GitHub repository """ - github_latest_version, github_url = get_latest_version() + latest_release, github_url = get_latest_release() latest_version_str = None latest_version_url = None - if isinstance(github_latest_version, version.Version): + if isinstance(latest_release, version.Version): current_version = version.parse(django_settings.VERSION) - if github_latest_version > current_version: - latest_version_str = str(github_latest_version) + if latest_release > current_version: + latest_version_str = str(latest_release) latest_version_url = github_url return { diff --git a/netbox/utilities/versions.py b/netbox/utilities/versions.py index 40d5d90af..72341c8f7 100644 --- a/netbox/utilities/versions.py +++ b/netbox/utilities/versions.py @@ -3,25 +3,33 @@ from cacheops import cached from django.conf import settings from packaging import version -if settings.GITHUB_VERSION_TIMEOUT and settings.GITHUB_REPOSITORY: - @cached(timeout=settings.GITHUB_VERSION_TIMEOUT) - def get_latest_version(): - url = 'https://api.github.com/repos/{}/releases'.format(settings.GITHUB_REPOSITORY) - headers = { - 'Accept': 'application/vnd.github.v3+json', - } - try: - response = requests.get(url, headers=headers) - versions = [(version.parse(release['tag_name']), release.get('html_url')) - for release in response.json() - if 'tag_name' in release] - if versions: - return max(versions) - except Exception: - pass - return 'unknown', None +@cached(timeout=settings.GITHUB_VERSION_TIMEOUT if settings.GITHUB_VERSION_TIMEOUT > 0 else 1) +def get_releases(pre_releases=False): + url = 'https://api.github.com/repos/{}/releases'.format(settings.GITHUB_REPOSITORY) + headers = { + 'Accept': 'application/vnd.github.v3+json', + } + try: + response = requests.get(url, headers=headers) + releases = [(version.parse(release['tag_name']), release.get('html_url')) + for release in response.json() + if 'tag_name' in release] + except Exception: + releases = [] -else: - def get_latest_version(): - return None + if not pre_releases: + releases = [(release, url) + for release, url in releases + if not release.is_devrelease and not release.is_prerelease] + + return releases + + +def get_latest_release(pre_releases=False): + if settings.GITHUB_VERSION_TIMEOUT > 0 and settings.GITHUB_REPOSITORY: + releases = get_releases(pre_releases) + if releases: + return max(releases) + + return 'unknown', None From 008fc5623e692f57b282a9ee49a3fd2d8c74347e Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Sun, 26 Jan 2020 16:50:15 +0100 Subject: [PATCH 08/66] Full URL for API, more consistent naming, only enabled for staff and better configuration validation --- docs/configuration/optional-settings.md | 12 +++---- netbox/netbox/configuration.example.py | 7 ++-- netbox/netbox/settings.py | 33 +++++++++++++++---- netbox/netbox/views.py | 21 ++++++++++-- netbox/templates/_base.html | 11 +------ netbox/templates/home.html | 12 +++++++ netbox/utilities/context_processors.py | 23 ------------- netbox/utilities/{versions.py => releases.py} | 6 ++-- 8 files changed, 70 insertions(+), 55 deletions(-) rename netbox/utilities/{versions.py => releases.py} (77%) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 36687343a..5a3121960 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -157,19 +157,19 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni --- -## GITHUB_REPOSITORY +## GITHUB_REPOSITORY_API -Default: 'netbox-community/netbox' +Default: 'https://api.github.com/repos/netbox-community/netbox' -The tags of this repository are checked to detect new releases, which are shown in the footer of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. +The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. --- -## GITHUB_VERSION_TIMEOUT +## GITHUB_CACHE_TIMEOUT -Default: 8 * 3600 +Default: 24 * 3600 -The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. Set to 0 to disable the version check. +The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. This must be set to at least one hour (3600 seconds). --- diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 63521789a..b24db0f03 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -126,11 +126,10 @@ EXEMPT_VIEW_PERMISSIONS = [ # This repository is used to check whether there is a new release of NetBox available. Set to None to disable the # version check. -GITHUB_REPOSITORY = 'netbox-community/netbox' +GITHUB_REPOSITORY_API = 'https://api.github.com/repos/netbox-community/netbox' -# This determines how often the GitHub API is called to check the latest release of NetBox. Set to 0 to disable the -# version check. -GITHUB_VERSION_TIMEOUT = 8 * 3600 +# This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour. +GITHUB_CACHE_TIMEOUT = 24 * 3600 # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs: # https://docs.djangoproject.com/en/stable/topics/logging/ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 1e598f85d..43b5428f9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -4,6 +4,7 @@ import platform import re import socket import warnings +from urllib.parse import urlsplit from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured @@ -79,8 +80,9 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False) EMAIL = getattr(configuration, 'EMAIL', {}) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) -GITHUB_REPOSITORY = getattr(configuration, 'GITHUB_REPOSITORY', 'netbox-community/netbox') -GITHUB_VERSION_TIMEOUT = getattr(configuration, 'GITHUB_VERSION_TIMEOUT', 8 * 3600) +GITHUB_REPOSITORY_API = getattr(configuration, 'GITHUB_REPOSITORY_API', + 'https://api.github.com/repos/netbox-community/netbox') +GITHUB_CACHE_TIMEOUT = getattr(configuration, 'GITHUB_CACHE_TIMEOUT', 24 * 3600) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) @@ -295,7 +297,6 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'utilities.context_processors.settings', - 'utilities.context_processors.latest_version', ], }, }, @@ -307,10 +308,28 @@ AUTHENTICATION_BACKENDS = [ ] # GitHub repository for version check -if GITHUB_REPOSITORY and not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', GITHUB_REPOSITORY): - raise ImproperlyConfigured( - "GITHUB_REPOSITORY must contain the name of a GitHub repository in the form '/'" - ) +if GITHUB_REPOSITORY_API: + GITHUB_REPOSITORY_API = GITHUB_REPOSITORY_API.rstrip('/') + try: + scheme, netloc, path, query, fragment = urlsplit(GITHUB_REPOSITORY_API) + except ValueError: + raise ImproperlyConfigured("GITHUB_REPOSITORY_API must be a valid URL") + + if scheme not in ('http', 'https'): + raise ImproperlyConfigured("GITHUB_REPOSITORY_API must be a valid http:// or https:// URL") + + if not re.fullmatch(r'/repos/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', path): + raise ImproperlyConfigured( + "GITHUB_REPOSITORY must contain the base URL of the GitHub API in a form like " + "'https://api.github.com/repos//'" + ) + + if query or fragment: + raise ImproperlyConfigured("GITHUB_REPOSITORY_API may not contain a query or fragment") + +# Enforce a cache timeout of at least an hour to protect GitHub +if GITHUB_CACHE_TIMEOUT < 3600: + raise ImproperlyConfigured("GITHUB_CACHE_TIMEOUT has to be at least 3600 seconds (1 hour)") # Internationalization LANGUAGE_CODE = 'en-us' diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 05bcea90d..9afa9c73a 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,8 +1,10 @@ from collections import OrderedDict -from django.db.models import Count, F +from django.conf import settings +from django.db.models import Count, F, OuterRef, Subquery from django.shortcuts import render from django.views.generic import View +from packaging import version from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView @@ -31,6 +33,7 @@ from secrets.tables import SecretTable from tenancy.filters import TenantFilterSet from tenancy.models import Tenant from tenancy.tables import TenantTable +from utilities.releases import get_latest_release from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine from virtualization.tables import ClusterTable, VirtualMachineDetailTable @@ -240,11 +243,25 @@ class HomeView(View): } + new_release = None + new_release_url = None + + if request.user.is_staff: + # Only check for new releases if the current user might be able to do anything about it + latest_release, github_url = get_latest_release() + if isinstance(latest_release, version.Version): + current_version = version.parse(settings.VERSION) + if latest_release > current_version: + new_release = str(latest_release) + new_release_url = github_url + return render(request, self.template_name, { 'search_form': SearchForm(), 'stats': stats, 'report_results': ReportResult.objects.order_by('-created')[:10], - 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15] + 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15], + 'new_release': new_release, + 'new_release_url': new_release_url, }) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index cfc2a9cc8..1b7a9da80 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -50,16 +50,7 @@
-

- {{ settings.HOSTNAME }} (v{{ settings.VERSION }}) - {% if latest_version %} - {% if latest_version_url %}{% endif %} - - New version: {{ latest_version }} - - {% if latest_version_url %}{% endif %} - {% endif %} -

+

{{ settings.HOSTNAME }} (v{{ settings.VERSION }})

{% now 'Y-m-d H:i:s T' %}

diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 6977bba4c..ed6ef06d6 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -1,6 +1,18 @@ {% extends '_base.html' %} {% load helpers %} +{% block header %} + {% if new_release %} + + {% endif %} +{% endblock %} + + {% block content %} {% include 'search_form.html' %}
diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index 5dadf55da..06c5c8784 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -1,7 +1,4 @@ from django.conf import settings as django_settings -from packaging import version - -from utilities.versions import get_latest_release def settings(request): @@ -11,23 +8,3 @@ def settings(request): return { 'settings': django_settings, } - - -def latest_version(request): - """ - Get the latest version from the GitHub repository - """ - latest_release, github_url = get_latest_release() - - latest_version_str = None - latest_version_url = None - if isinstance(latest_release, version.Version): - current_version = version.parse(django_settings.VERSION) - if latest_release > current_version: - latest_version_str = str(latest_release) - latest_version_url = github_url - - return { - 'latest_version': latest_version_str, - 'latest_version_url': latest_version_url - } diff --git a/netbox/utilities/versions.py b/netbox/utilities/releases.py similarity index 77% rename from netbox/utilities/versions.py rename to netbox/utilities/releases.py index 72341c8f7..6fb422c00 100644 --- a/netbox/utilities/versions.py +++ b/netbox/utilities/releases.py @@ -4,9 +4,9 @@ from django.conf import settings from packaging import version -@cached(timeout=settings.GITHUB_VERSION_TIMEOUT if settings.GITHUB_VERSION_TIMEOUT > 0 else 1) +@cached(timeout=settings.GITHUB_CACHE_TIMEOUT, extra=settings.GITHUB_REPOSITORY_API) def get_releases(pre_releases=False): - url = 'https://api.github.com/repos/{}/releases'.format(settings.GITHUB_REPOSITORY) + url = '{}/releases'.format(settings.GITHUB_REPOSITORY_API) headers = { 'Accept': 'application/vnd.github.v3+json', } @@ -27,7 +27,7 @@ def get_releases(pre_releases=False): def get_latest_release(pre_releases=False): - if settings.GITHUB_VERSION_TIMEOUT > 0 and settings.GITHUB_REPOSITORY: + if settings.GITHUB_REPOSITORY_API: releases = get_releases(pre_releases) if releases: return max(releases) From 3a0849699fac7003bd5c6b0b232e18c5b5c0c91a Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Thu, 27 Feb 2020 18:15:31 +0100 Subject: [PATCH 09/66] Rename settings to be more generic, not GitHub-only --- docs/configuration/optional-settings.md | 8 ++++---- netbox/netbox/configuration.example.py | 4 ++-- netbox/netbox/settings.py | 22 +++++++++++----------- netbox/utilities/releases.py | 6 +++--- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 5a3121960..652d10afc 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -157,17 +157,17 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni --- -## GITHUB_REPOSITORY_API +## UPDATE_REPO_URL Default: 'https://api.github.com/repos/netbox-community/netbox' -The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. +The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. The URL provided **must** be compatible with the GitHub API. --- -## GITHUB_CACHE_TIMEOUT +## UPDATE_CACHE_TIMEOUT -Default: 24 * 3600 +Default: 86,400 (24 hours) The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. This must be set to at least one hour (3600 seconds). diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index b24db0f03..a90d286e7 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -126,10 +126,10 @@ EXEMPT_VIEW_PERMISSIONS = [ # This repository is used to check whether there is a new release of NetBox available. Set to None to disable the # version check. -GITHUB_REPOSITORY_API = 'https://api.github.com/repos/netbox-community/netbox' +UPDATE_REPO_URL = 'https://api.github.com/repos/netbox-community/netbox' # This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour. -GITHUB_CACHE_TIMEOUT = 24 * 3600 +UPDATE_CACHE_TIMEOUT = 24 * 3600 # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs: # https://docs.djangoproject.com/en/stable/topics/logging/ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 43b5428f9..ce0ed7ce7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -80,9 +80,9 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False) EMAIL = getattr(configuration, 'EMAIL', {}) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) -GITHUB_REPOSITORY_API = getattr(configuration, 'GITHUB_REPOSITORY_API', - 'https://api.github.com/repos/netbox-community/netbox') -GITHUB_CACHE_TIMEOUT = getattr(configuration, 'GITHUB_CACHE_TIMEOUT', 24 * 3600) +UPDATE_REPO_URL = getattr(configuration, 'UPDATE_REPO_URL', + 'https://api.github.com/repos/netbox-community/netbox') +UPDATE_CACHE_TIMEOUT = getattr(configuration, 'UPDATE_CACHE_TIMEOUT', 24 * 3600) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) @@ -308,15 +308,15 @@ AUTHENTICATION_BACKENDS = [ ] # GitHub repository for version check -if GITHUB_REPOSITORY_API: - GITHUB_REPOSITORY_API = GITHUB_REPOSITORY_API.rstrip('/') +if UPDATE_REPO_URL: + UPDATE_REPO_URL = UPDATE_REPO_URL.rstrip('/') try: - scheme, netloc, path, query, fragment = urlsplit(GITHUB_REPOSITORY_API) + scheme, netloc, path, query, fragment = urlsplit(UPDATE_REPO_URL) except ValueError: - raise ImproperlyConfigured("GITHUB_REPOSITORY_API must be a valid URL") + raise ImproperlyConfigured("UPDATE_REPO_URL must be a valid URL") if scheme not in ('http', 'https'): - raise ImproperlyConfigured("GITHUB_REPOSITORY_API must be a valid http:// or https:// URL") + raise ImproperlyConfigured("UPDATE_REPO_URL must be a valid http:// or https:// URL") if not re.fullmatch(r'/repos/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', path): raise ImproperlyConfigured( @@ -325,11 +325,11 @@ if GITHUB_REPOSITORY_API: ) if query or fragment: - raise ImproperlyConfigured("GITHUB_REPOSITORY_API may not contain a query or fragment") + raise ImproperlyConfigured("UPDATE_REPO_URL may not contain a query or fragment") # Enforce a cache timeout of at least an hour to protect GitHub -if GITHUB_CACHE_TIMEOUT < 3600: - raise ImproperlyConfigured("GITHUB_CACHE_TIMEOUT has to be at least 3600 seconds (1 hour)") +if UPDATE_CACHE_TIMEOUT < 3600: + raise ImproperlyConfigured("UPDATE_CACHE_TIMEOUT has to be at least 3600 seconds (1 hour)") # Internationalization LANGUAGE_CODE = 'en-us' diff --git a/netbox/utilities/releases.py b/netbox/utilities/releases.py index 6fb422c00..30ee8c295 100644 --- a/netbox/utilities/releases.py +++ b/netbox/utilities/releases.py @@ -4,9 +4,9 @@ from django.conf import settings from packaging import version -@cached(timeout=settings.GITHUB_CACHE_TIMEOUT, extra=settings.GITHUB_REPOSITORY_API) +@cached(timeout=settings.UPDATE_CACHE_TIMEOUT, extra=settings.UPDATE_REPO_URL) def get_releases(pre_releases=False): - url = '{}/releases'.format(settings.GITHUB_REPOSITORY_API) + url = '{}/releases'.format(settings.UPDATE_REPO_URL) headers = { 'Accept': 'application/vnd.github.v3+json', } @@ -27,7 +27,7 @@ def get_releases(pre_releases=False): def get_latest_release(pre_releases=False): - if settings.GITHUB_REPOSITORY_API: + if settings.UPDATE_REPO_URL: releases = get_releases(pre_releases) if releases: return max(releases) From 8d92089487c8116edf4fdfb0aa825ce9c29e308a Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Thu, 27 Feb 2020 18:18:05 +0100 Subject: [PATCH 10/66] Improve comments and error message on invalid characters in URL --- netbox/netbox/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ce0ed7ce7..aaac8a153 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -324,8 +324,9 @@ if UPDATE_REPO_URL: "'https://api.github.com/repos//'" ) + # Don't allow ? (query) and # (fragment) in the URL if query or fragment: - raise ImproperlyConfigured("UPDATE_REPO_URL may not contain a query or fragment") + raise ImproperlyConfigured("UPDATE_REPO_URL may not contain a ? (query) or # (fragment)") # Enforce a cache timeout of at least an hour to protect GitHub if UPDATE_CACHE_TIMEOUT < 3600: From 0de7f4712f82ec27ccf48685570b984d1a987fad Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Thu, 27 Feb 2020 18:20:32 +0100 Subject: [PATCH 11/66] Fix check for permissions --- netbox/netbox/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 9afa9c73a..7634893a7 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -246,7 +246,7 @@ class HomeView(View): new_release = None new_release_url = None - if request.user.is_staff: + if request.user.is_staff or request.user.is_superuser: # Only check for new releases if the current user might be able to do anything about it latest_release, github_url = get_latest_release() if isinstance(latest_release, version.Version): From 22ac9f63a122bf16fcb58915a83823855152de27 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Thu, 27 Feb 2020 18:21:31 +0100 Subject: [PATCH 12/66] Don't overwrite the header block, append to it --- netbox/templates/home.html | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/templates/home.html b/netbox/templates/home.html index ed6ef06d6..b560d8894 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -2,6 +2,7 @@ {% load helpers %} {% block header %} + {{ block.super }} {% if new_release %} + +
+
+
+ {% if perms.dcim.change_rackreservation %} + {% edit_button rackreservation %} + {% endif %} + {% if perms.dcim.delete_rackreservation %} + {% delete_button rackreservation %} + {% endif %} +
+

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

+ {% include 'inc/created_updated.html' with obj=rackreservation %} + +{% endblock %} + +{% block content %} +
+
+
+
+ Rack +
+ + {% with rack=rackreservation.rack %} + + + + + + + + + + + + + {% endwith %} +
Site + {% if rack.site.region %} + {{ rack.site.region }} + + {% endif %} + {{ rack.site }} +
Group + {% if rack.group %} + {{ rack.group }} + {% else %} + None + {% endif %} +
Rack + {{ rack }} +
+
+
+
+ Reservation Details +
+ + + + + + + + + + + + + + + + + +
Units{{ rackreservation.unit_list }}
Tenant + {% if rackreservation.tenant %} + {% if rackreservation.tenant.group %} + {{ rackreservation.tenant.group }} + + {% endif %} + {{ rackreservation.tenant }} + {% else %} + None + {% endif %} +
User{{ rackreservation.user }}
Description{{ rackreservation.description }}
+
+
+
+ {% with rack=rackreservation.rack %} +
+
+
+

Front

+
+ {% include 'dcim/inc/rack_elevation.html' with face='front' %} +
+
+
+

Rear

+
+ {% include 'dcim/inc/rack_elevation.html' with face='rear' %} +
+
+ {% endwith %} +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} From 9466802a95cbaa773d97627b0a3e4af4601dff5b Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sat, 14 Mar 2020 03:03:22 -0400 Subject: [PATCH 28/66] closes #4368 - extras features model registration --- netbox/circuits/models.py | 3 + netbox/dcim/models/__init__.py | 12 ++ netbox/dcim/models/device_components.py | 10 + netbox/extras/api/serializers.py | 5 +- netbox/extras/constants.py | 181 +----------------- .../0039_update_features_content_types.py | 40 ++++ netbox/extras/models.py | 11 +- netbox/extras/tests/test_api.py | 4 +- netbox/extras/tests/test_filters.py | 6 +- netbox/extras/utils.py | 68 +++++++ netbox/extras/webhooks.py | 3 +- netbox/ipam/models.py | 7 + netbox/secrets/models.py | 2 + netbox/tenancy/models.py | 2 + netbox/virtualization/models.py | 3 + 15 files changed, 172 insertions(+), 185 deletions(-) create mode 100644 netbox/extras/migrations/0039_update_features_content_types.py diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 812eaa79e..919fc45a5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * @@ -21,6 +22,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -131,6 +133,7 @@ class CircuitType(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Circuit(ChangeLoggedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 94e8a2391..63c3044c1 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -21,6 +21,7 @@ from dcim.constants import * from dcim.fields import ASNField from dcim.elevations import RackElevationSVG from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.fields import ColorField, NaturalOrderingField from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object, to_meters @@ -75,6 +76,7 @@ __all__ = ( # Regions # +@extras_features('export_templates', 'webhooks') class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. @@ -133,6 +135,7 @@ class Region(MPTTModel, ChangeLoggedModel): # Sites # +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Site(ChangeLoggedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -283,6 +286,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): # Racks # +@extras_features('export_templates') class RackGroup(ChangeLoggedModel): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For @@ -359,6 +363,7 @@ class RackRole(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Rack(ChangeLoggedModel, CustomFieldModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. @@ -823,6 +828,7 @@ class RackReservation(ChangeLoggedModel): # Device Types # +@extras_features('export_templates', 'webhooks') class Manufacturer(ChangeLoggedModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -853,6 +859,7 @@ class Manufacturer(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class DeviceType(ChangeLoggedModel, CustomFieldModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -1196,6 +1203,7 @@ class Platform(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'graphs', '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, @@ -1631,6 +1639,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Virtual chassis # +@extras_features('export_templates', 'webhooks') class VirtualChassis(ChangeLoggedModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). @@ -1697,6 +1706,7 @@ class VirtualChassis(ChangeLoggedModel): # Power # +@extras_features('custom_links', 'export_templates', 'webhooks') class PowerPanel(ChangeLoggedModel): """ A distribution point for electrical power; e.g. a data center RPP. @@ -1743,6 +1753,7 @@ class PowerPanel(ChangeLoggedModel): )) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): """ An electrical circuit delivered from a PowerPanel. @@ -1904,6 +1915,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): # Cables # +@extras_features('custom_links', 'export_templates', 'webhooks') class Cable(ChangeLoggedModel): """ A physical connection between two endpoints. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9a3d608d7..806d652b7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,6 +11,7 @@ from dcim.constants import * from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.utils import serialize_object @@ -169,6 +170,7 @@ class CableTermination(models.Model): # Console ports # +@extras_features('export_templates', 'webhooks') class ConsolePort(CableTermination, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -229,6 +231,7 @@ class ConsolePort(CableTermination, ComponentModel): # Console server ports # +@extras_features('webhooks') class ConsoleServerPort(CableTermination, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -282,6 +285,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): # Power ports # +@extras_features('export_templates', 'webhooks') class PowerPort(CableTermination, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -443,6 +447,7 @@ class PowerPort(CableTermination, ComponentModel): # Power outlets # +@extras_features('webhooks') class PowerOutlet(CableTermination, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -519,6 +524,7 @@ class PowerOutlet(CableTermination, ComponentModel): # Interfaces # +@extras_features('graphs', 'export_templates', 'webhooks') class Interface(CableTermination, ComponentModel): """ A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other @@ -792,6 +798,7 @@ class Interface(CableTermination, ComponentModel): # Pass-through ports # +@extras_features('webhooks') class FrontPort(CableTermination, ComponentModel): """ A pass-through port on the front of a Device. @@ -864,6 +871,7 @@ class FrontPort(CableTermination, ComponentModel): ) +@extras_features('webhooks') class RearPort(CableTermination, ComponentModel): """ A pass-through port on the rear of a Device. @@ -915,6 +923,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # +@extras_features('webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -989,6 +998,7 @@ class DeviceBay(ComponentModel): # Inventory items # +@extras_features('export_templates', 'webhooks') class InventoryItem(ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 40606ed8e..567beedb0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,6 +13,7 @@ from extras.constants import * from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, ) +from extras.utils import FeatureQuerySet from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -31,7 +32,7 @@ from .nested_serializers import * class GraphSerializer(ValidatedModelSerializer): type = ContentTypeField( - queryset=ContentType.objects.filter(GRAPH_MODELS), + queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()), ) class Meta: @@ -67,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): content_type = ContentTypeField( - queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS), + queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()), ) template_language = ChoiceField( choices=TemplateLanguageChoices, diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 7bb026d34..3b6c044dc 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,129 +1,3 @@ -from django.db.models import Q - - -# Models which support custom fields -CUSTOMFIELD_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'device', - 'devicetype', - 'powerfeed', - 'rack', - 'site', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - -# Custom links -CUSTOMLINK_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'device', - 'devicetype', - 'powerpanel', - 'powerfeed', - 'rack', - 'site', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - -# Models which can have Graphs associated with them -GRAPH_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'device', - 'interface', - 'site', - ]) -) - -# Models which support export templates -EXPORTTEMPLATE_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'consoleport', - 'device', - 'devicetype', - 'interface', - 'inventoryitem', - 'manufacturer', - 'powerpanel', - 'powerport', - 'powerfeed', - 'rack', - 'rackgroup', - 'region', - 'site', - 'virtualchassis', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) - # Report logging levels LOG_DEFAULT = 0 LOG_SUCCESS = 10 @@ -138,51 +12,14 @@ LOG_LEVEL_CODES = { LOG_FAILURE: 'failure', } +# Webhook content types HTTP_CONTENT_TYPE_JSON = 'application/json' -# Models which support registered webhooks -WEBHOOK_MODELS = Q( - Q(app_label='circuits', model__in=[ - 'circuit', - 'provider', - ]) | - Q(app_label='dcim', model__in=[ - 'cable', - 'consoleport', - 'consoleserverport', - 'device', - 'devicebay', - 'devicetype', - 'frontport', - 'interface', - 'inventoryitem', - 'manufacturer', - 'poweroutlet', - 'powerpanel', - 'powerport', - 'powerfeed', - 'rack', - 'rearport', - 'region', - 'site', - 'virtualchassis', - ]) | - Q(app_label='ipam', model__in=[ - 'aggregate', - 'ipaddress', - 'prefix', - 'service', - 'vlan', - 'vrf', - ]) | - Q(app_label='secrets', model__in=[ - 'secret', - ]) | - Q(app_label='tenancy', model__in=[ - 'tenant', - ]) | - Q(app_label='virtualization', model__in=[ - 'cluster', - 'virtualmachine', - ]) -) +# Registerable extras features +EXTRAS_FEATURES = [ + 'custom_fields', + 'custom_links', + 'graphs', + 'export_templates', + 'webhooks' +] diff --git a/netbox/extras/migrations/0039_update_features_content_types.py b/netbox/extras/migrations/0039_update_features_content_types.py new file mode 100644 index 000000000..c347b1198 --- /dev/null +++ b/netbox/extras/migrations/0039_update_features_content_types.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.11 on 2020-03-14 06:50 + +from django.db import migrations, models +import django.db.models.deletion +import extras.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0038_webhook_template_support'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='customlink', + name='content_type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='graph', + name='type', + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='webhook', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('webhooks'), related_name='webhooks', to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index d81fbeab9..21809c35b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -22,6 +22,7 @@ from utilities.utils import deepmerge, render_jinja2 from .choices import * from .constants import * from .querysets import ConfigContextQuerySet +from .utils import FeatureQuerySet __all__ = ( @@ -58,7 +59,7 @@ class Webhook(models.Model): to=ContentType, related_name='webhooks', verbose_name='Object types', - limit_choices_to=WEBHOOK_MODELS, + limit_choices_to=FeatureQuerySet('webhooks'), help_text="The object(s) to which this Webhook applies." ) name = models.CharField( @@ -223,7 +224,7 @@ class CustomField(models.Model): to=ContentType, related_name='custom_fields', verbose_name='Object(s)', - limit_choices_to=CUSTOMFIELD_MODELS, + limit_choices_to=FeatureQuerySet('custom_fields'), help_text='The object(s) to which this field applies.' ) type = models.CharField( @@ -470,7 +471,7 @@ class CustomLink(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=CUSTOMLINK_MODELS + limit_choices_to=FeatureQuerySet('custom_links') ) name = models.CharField( max_length=100, @@ -518,7 +519,7 @@ class Graph(models.Model): type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=GRAPH_MODELS + limit_choices_to=FeatureQuerySet('graphs') ) weight = models.PositiveSmallIntegerField( default=1000 @@ -581,7 +582,7 @@ class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=EXPORTTEMPLATE_MODELS + limit_choices_to=FeatureQuerySet('export_templates') ) name = models.CharField( max_length=100 diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 3e6e43789..773314942 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -8,9 +8,9 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site from extras.api.views import ScriptViewSet from extras.choices import * -from extras.constants import GRAPH_MODELS from extras.models import ConfigContext, Graph, ExportTemplate, Tag from extras.scripts import BooleanVar, IntegerVar, Script, StringVar +from extras.utils import FeatureQuerySet from tenancy.models import Tenant, TenantGroup from utilities.testing import APITestCase, choices_to_dict @@ -35,7 +35,7 @@ class AppTest(APITestCase): self.assertEqual(choices_to_dict(response.data.get('export-template:template_language')), TemplateLanguageChoices.as_dict()) # Graph - content_types = ContentType.objects.filter(GRAPH_MODELS) + content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()) graph_type_choices = { "{}.{}".format(ct.app_label, ct.model): ct.name for ct in content_types } diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index ab559cf73..e507e4034 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -3,8 +3,8 @@ from django.test import TestCase from dcim.models import DeviceRole, Platform, Region, Site from extras.choices import * -from extras.constants import GRAPH_MODELS from extras.filters import * +from extras.utils import FeatureQuerySet from extras.models import ConfigContext, ExportTemplate, Graph from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -18,7 +18,7 @@ class GraphTestCase(TestCase): def setUpTestData(cls): # Get the first three available types - content_types = ContentType.objects.filter(GRAPH_MODELS)[:3] + content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())[:3] graphs = ( Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'), @@ -32,7 +32,7 @@ class GraphTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - content_type = ContentType.objects.filter(GRAPH_MODELS).first() + content_type = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()).first() params = {'type': content_type.pk} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index ca3a72526..5edf3f562 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,6 +1,12 @@ +import collections + +from django.db.models import Q +from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from utilities.querysets import DummyQuerySet +from extras.constants import EXTRAS_FEATURES + def is_taggable(obj): """ @@ -13,3 +19,65 @@ def is_taggable(obj): if isinstance(obj.tags, DummyQuerySet): return True return False + + +# +# Dynamic feature registration +# + +class Registry: + """ + The registry is a place to hook into for data storage across components + """ + + def add_store(self, store_name, initial_value=None): + """ + Given the name of some new data parameter and an optional initial value, setup the registry store + """ + if not hasattr(Registry, store_name): + setattr(Registry, store_name, initial_value) + + +registry = Registry() + + +@deconstructible +class FeatureQuerySet: + """ + Helper class that delays evaluation of the registry contents for the functionaility store + until it has been populated. + """ + + def __init__(self, feature): + self.feature = feature + + def __call__(self): + return self.get_queryset() + + def get_queryset(self): + """ + Given an extras feature, return a Q object for content type lookup + """ + query = Q() + for app_label, models in registry.model_feature_store[self.feature].items(): + query |= Q(app_label=app_label, model__in=models) + + return query + + +registry.add_store('model_feature_store', {f: collections.defaultdict(list) for f in EXTRAS_FEATURES}) + + +def extras_features(*features): + """ + Decorator used to register extras provided features to a model + """ + def wrapper(model_class): + for feature in features: + if feature in EXTRAS_FEATURES: + app_label, model_name = model_class._meta.label_lower.split('.') + registry.model_feature_store[feature][app_label].append(model_name) + else: + raise ValueError('{} is not a valid extras feature!'.format(feature)) + return model_class + return wrapper diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 8b20641d7..f1a3391a0 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -8,6 +8,7 @@ from extras.models import Webhook from utilities.api import get_serializer_for_model from .choices import * from .constants import * +from .utils import FeatureQuerySet def generate_signature(request_body, secret): @@ -29,7 +30,7 @@ def enqueue_webhooks(instance, user, request_id, action): """ obj_type = ContentType.objects.get_for_model(instance.__class__) - webhook_models = ContentType.objects.filter(WEBHOOK_MODELS) + webhook_models = ContentType.objects.filter(FeatureQuerySet('webhooks').get_queryset()) if obj_type not in webhook_models: return diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 4cbcb4bf0..0ffce07cf 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -10,6 +10,7 @@ from taggit.managers import TaggableManager from dcim.models import Device, Interface from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from virtualization.models import VirtualMachine @@ -34,6 +35,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VRF(ChangeLoggedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -145,6 +147,7 @@ class RIR(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Aggregate(ChangeLoggedModel, CustomFieldModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -285,6 +288,7 @@ class Role(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Prefix(ChangeLoggedModel, CustomFieldModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -551,6 +555,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): return int(float(child_count) / prefix_size * 100) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class IPAddress(ChangeLoggedModel, CustomFieldModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is @@ -854,6 +859,7 @@ class VLANGroup(ChangeLoggedModel): return None +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VLAN(ChangeLoggedModel, CustomFieldModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned @@ -978,6 +984,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): ).distinct() +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Service(ChangeLoggedModel, CustomFieldModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 7cebb744c..123135eec 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -16,6 +16,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -295,6 +296,7 @@ class SecretRole(ChangeLoggedModel): return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists() +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 9fa7f23ea..757728fbb 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -4,6 +4,7 @@ from django.urls import reverse from taggit.managers import TaggableManager from extras.models import CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel @@ -43,6 +44,7 @@ class TenantGroup(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 13b181137..2bd391863 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,6 +7,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .choices import * @@ -91,6 +92,7 @@ class ClusterGroup(ChangeLoggedModel): # Clusters # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -177,6 +179,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # Virtual machines # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. From 7ef9a6c0a75d9409b53d4a8f58cec3bdf3b1aa6e Mon Sep 17 00:00:00 2001 From: kobayashi Date: Mon, 16 Mar 2020 03:20:15 -0400 Subject: [PATCH 29/66] Fixes #2769: improve prefix_length validations --- docs/release-notes/version-2.7.md | 1 + netbox/ipam/api/serializers.py | 27 +++++++++++++++++ netbox/ipam/api/views.py | 50 ++++++++++--------------------- netbox/ipam/tests/test_api.py | 7 ++++- 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index f7460d92e..b34731403 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ ### Bug Fixes +* [#2769](https://github.com/netbox-community/netbox/issues/2769) - Improve `prefix_length` validation on available-prefixes API * [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API * [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables * [#4365](https://github.com/netbox-community/netbox/issues/4365) - Fix exception raised on IP address bulk add view diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e6d9adecd..c597aaf48 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -154,6 +154,33 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): read_only_fields = ['family'] +class PrefixLengthSerializer(serializers.Serializer): + + prefix_length = serializers.IntegerField() + + def to_internal_value(self, data): + requested_prefix = data.get('prefix_length') + if requested_prefix is None: + raise serializers.ValidationError({ + 'prefix_length': 'this field can not be missing' + }) + if not isinstance(requested_prefix, int): + raise serializers.ValidationError({ + 'prefix_length': 'this field must be int type' + }) + + prefix = self.context.get('prefix') + if prefix.family == 4 and requested_prefix > 32: + raise serializers.ValidationError({ + 'prefix_length': 'Invalid prefix length ({}) for IPv4'.format((requested_prefix)) + }) + elif prefix.family == 6 and requested_prefix > 128: + raise serializers.ValidationError({ + 'prefix_length': 'Invalid prefix length ({}) for IPv6'.format((requested_prefix)) + }) + return data + + class AvailablePrefixSerializer(serializers.Serializer): """ Representation of a prefix which does not exist in the database. diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 262ca7908..ae6880209 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -105,45 +105,25 @@ class PrefixViewSet(CustomFieldModelViewSet): if not request.user.has_perm('ipam.add_prefix'): raise PermissionDenied() - # Normalize to a list of objects - requested_prefixes = request.data if isinstance(request.data, list) else [request.data] + # Validate Requested Prefixes' length + serializer = serializers.PrefixLengthSerializer( + data=request.data if isinstance(request.data, list) else [request.data], + many=True, + context={ + 'request': request, + 'prefix': prefix, + } + ) + if not serializer.is_valid(): + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST + ) + requested_prefixes = serializer.validated_data # Allocate prefixes to the requested objects based on availability within the parent for i, requested_prefix in enumerate(requested_prefixes): - # Validate requested prefix size - prefix_length = requested_prefix.get('prefix_length') - if prefix_length is None: - return Response( - { - "detail": "Item {}: prefix_length field missing".format(i) - }, - status=status.HTTP_400_BAD_REQUEST - ) - try: - prefix_length = int(prefix_length) - except ValueError: - return Response( - { - "detail": "Item {}: Invalid prefix length ({})".format(i, prefix_length), - }, - status=status.HTTP_400_BAD_REQUEST - ) - if prefix.family == 4 and prefix_length > 32: - return Response( - { - "detail": "Item {}: Invalid prefix length ({}) for IPv4".format(i, prefix_length), - }, - status=status.HTTP_400_BAD_REQUEST - ) - elif prefix.family == 6 and prefix_length > 128: - return Response( - { - "detail": "Item {}: Invalid prefix length ({}) for IPv6".format(i, prefix_length), - }, - status=status.HTTP_400_BAD_REQUEST - ) - # Find the first available prefix equal to or larger than the requested size for available_prefix in available_prefixes.iter_cidrs(): if requested_prefix['prefix_length'] >= available_prefix.prefixlen: diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 99a7eaca4..2b8ddd649 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -611,10 +611,15 @@ class PrefixTest(APITestCase): self.assertEqual(response.data['description'], data['description']) # Try to create one more prefix - response = self.client.post(url, {'prefix_length': 30}, **self.header) + response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) + # Try to create invalid prefix type + response = self.client.post(url, {'prefix_length': '30'}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('prefix_length', response.data[0]) + def test_create_multiple_available_prefixes(self): prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True) From 9a38586e13375584bf82f2b5ae92348ef2df3626 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Mon, 16 Mar 2020 11:58:35 -0400 Subject: [PATCH 30/66] rename FeatureQuery class --- netbox/extras/api/serializers.py | 6 +++--- .../migrations/0039_update_features_content_types.py | 10 +++++----- netbox/extras/models.py | 12 ++++++------ netbox/extras/tests/test_api.py | 4 ++-- netbox/extras/tests/test_filters.py | 6 +++--- netbox/extras/utils.py | 6 +++--- netbox/extras/webhooks.py | 4 ++-- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 567beedb0..41ddb8d8b 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -13,7 +13,7 @@ from extras.constants import * from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, ) -from extras.utils import FeatureQuerySet +from extras.utils import FeatureQuery from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -32,7 +32,7 @@ from .nested_serializers import * class GraphSerializer(ValidatedModelSerializer): type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()), + queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()), ) class Meta: @@ -68,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): content_type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()), + queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), ) template_language = ChoiceField( choices=TemplateLanguageChoices, diff --git a/netbox/extras/migrations/0039_update_features_content_types.py b/netbox/extras/migrations/0039_update_features_content_types.py index c347b1198..8747fcc16 100644 --- a/netbox/extras/migrations/0039_update_features_content_types.py +++ b/netbox/extras/migrations/0039_update_features_content_types.py @@ -15,26 +15,26 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='customfield', name='obj_type', - field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'), + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'), ), migrations.AlterField( model_name='customlink', name='content_type', - field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), ), migrations.AlterField( model_name='exporttemplate', name='content_type', - field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), ), migrations.AlterField( model_name='graph', name='type', - field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), ), migrations.AlterField( model_name='webhook', name='obj_type', - field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('webhooks'), related_name='webhooks', to='contenttypes.ContentType'), + field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('webhooks'), related_name='webhooks', to='contenttypes.ContentType'), ), ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 21809c35b..97d681b00 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -22,7 +22,7 @@ from utilities.utils import deepmerge, render_jinja2 from .choices import * from .constants import * from .querysets import ConfigContextQuerySet -from .utils import FeatureQuerySet +from .utils import FeatureQuery __all__ = ( @@ -59,7 +59,7 @@ class Webhook(models.Model): to=ContentType, related_name='webhooks', verbose_name='Object types', - limit_choices_to=FeatureQuerySet('webhooks'), + limit_choices_to=FeatureQuery('webhooks'), help_text="The object(s) to which this Webhook applies." ) name = models.CharField( @@ -224,7 +224,7 @@ class CustomField(models.Model): to=ContentType, related_name='custom_fields', verbose_name='Object(s)', - limit_choices_to=FeatureQuerySet('custom_fields'), + limit_choices_to=FeatureQuery('custom_fields'), help_text='The object(s) to which this field applies.' ) type = models.CharField( @@ -471,7 +471,7 @@ class CustomLink(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=FeatureQuerySet('custom_links') + limit_choices_to=FeatureQuery('custom_links') ) name = models.CharField( max_length=100, @@ -519,7 +519,7 @@ class Graph(models.Model): type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=FeatureQuerySet('graphs') + limit_choices_to=FeatureQuery('graphs') ) weight = models.PositiveSmallIntegerField( default=1000 @@ -582,7 +582,7 @@ class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to=FeatureQuerySet('export_templates') + limit_choices_to=FeatureQuery('export_templates') ) name = models.CharField( max_length=100 diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 773314942..ad1b9d349 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -10,7 +10,7 @@ from extras.api.views import ScriptViewSet from extras.choices import * from extras.models import ConfigContext, Graph, ExportTemplate, Tag from extras.scripts import BooleanVar, IntegerVar, Script, StringVar -from extras.utils import FeatureQuerySet +from extras.utils import FeatureQuery from tenancy.models import Tenant, TenantGroup from utilities.testing import APITestCase, choices_to_dict @@ -35,7 +35,7 @@ class AppTest(APITestCase): self.assertEqual(choices_to_dict(response.data.get('export-template:template_language')), TemplateLanguageChoices.as_dict()) # Graph - content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()) + content_types = ContentType.objects.filter(FeatureQuery('graphs').get_query()) graph_type_choices = { "{}.{}".format(ct.app_label, ct.model): ct.name for ct in content_types } diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index e507e4034..e18574866 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -4,7 +4,7 @@ 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 FeatureQuerySet +from extras.utils import FeatureQuery from extras.models import ConfigContext, ExportTemplate, Graph from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -18,7 +18,7 @@ class GraphTestCase(TestCase): def setUpTestData(cls): # Get the first three available types - content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())[:3] + 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'), @@ -32,7 +32,7 @@ class GraphTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - content_type = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()).first() + 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) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 5edf3f562..5ae16bff9 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -42,7 +42,7 @@ registry = Registry() @deconstructible -class FeatureQuerySet: +class FeatureQuery: """ Helper class that delays evaluation of the registry contents for the functionaility store until it has been populated. @@ -52,9 +52,9 @@ class FeatureQuerySet: self.feature = feature def __call__(self): - return self.get_queryset() + return self.get_query() - def get_queryset(self): + def get_query(self): """ Given an extras feature, return a Q object for content type lookup """ diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index f1a3391a0..d1d5a59ab 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -8,7 +8,7 @@ from extras.models import Webhook from utilities.api import get_serializer_for_model from .choices import * from .constants import * -from .utils import FeatureQuerySet +from .utils import FeatureQuery def generate_signature(request_body, secret): @@ -30,7 +30,7 @@ def enqueue_webhooks(instance, user, request_id, action): """ obj_type = ContentType.objects.get_for_model(instance.__class__) - webhook_models = ContentType.objects.filter(FeatureQuerySet('webhooks').get_queryset()) + webhook_models = ContentType.objects.filter(FeatureQuery('webhooks').get_query()) if obj_type not in webhook_models: return From 400f6fc5fb45b907a5066a4f6b540cfeae41f5e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Mar 2020 13:30:35 -0400 Subject: [PATCH 31/66] Dynamically resolve the API URL for dynamic choice fields --- netbox/utilities/forms.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 8825102d1..420dbf03b 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count from django.forms import BoundField +from django.urls import reverse from .choices import unpack_grouped_choices from .constants import * @@ -269,7 +270,7 @@ class APISelect(SelectWithDisabled): """ def __init__( self, - api_url, + api_url=None, display_field=None, value_field=None, disabled_indicator=None, @@ -285,7 +286,8 @@ class APISelect(SelectWithDisabled): super().__init__(*args, **kwargs) self.attrs['class'] = 'netbox-select2-api' - self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH + if api_url: + self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH if full: self.attrs['data-full'] = full if display_field: @@ -566,6 +568,10 @@ class TagFilterField(forms.MultipleChoiceField): class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter + widget = APISelect + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) @@ -579,6 +585,14 @@ class DynamicModelChoiceMixin: else: self.queryset = self.queryset.none() + # Set the data URL on the APISelect widget (if not already set) + widget = bound_field.field.widget + if not widget.attrs.get('data-url'): + app_label = self.queryset.model._meta.app_label + model_name = self.queryset.model._meta.model_name + data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) + widget.attrs['data-url'] = data_url + return bound_field @@ -595,6 +609,7 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip A multiple-choice version of DynamicModelChoiceField. """ filter = django_filters.ModelMultipleChoiceFilter + widget = APISelectMultiple class LaxURLField(forms.URLField): From 0068108c57857f4314fb8fd8ede18b88edcb5481 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Mar 2020 14:08:48 -0400 Subject: [PATCH 32/66] Remove APISelect widget/api_url argument from dynamic fields --- netbox/circuits/forms.py | 31 +--- netbox/dcim/forms.py | 322 +++++---------------------------- netbox/extras/forms.py | 53 +----- netbox/ipam/forms.py | 125 +++---------- netbox/secrets/forms.py | 16 +- netbox/tenancy/forms.py | 19 +- netbox/utilities/forms.py | 2 +- netbox/virtualization/forms.py | 90 ++------- 8 files changed, 106 insertions(+), 552 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 0b0378a7a..bb685b402 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -113,7 +113,6 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -125,7 +124,6 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -167,16 +165,10 @@ class CircuitTypeCSVForm(forms.ModelForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - widget=APISelect( - api_url="/api/circuits/providers/" - ) + queryset=Provider.objects.all() ) type = DynamicModelChoiceField( - queryset=CircuitType.objects.all(), - widget=APISelect( - api_url="/api/circuits/circuit-types/" - ) + queryset=CircuitType.objects.all() ) comments = CommentField() tags = TagField( @@ -245,17 +237,11 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), - required=False, - widget=APISelect( - api_url="/api/circuits/circuit-types/" - ) + required=False ) provider = DynamicModelChoiceField( queryset=Provider.objects.all(), - required=False, - widget=APISelect( - api_url="/api/circuits/providers/" - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(CircuitStatusChoices), @@ -265,10 +251,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) commit_rate = forms.IntegerField( required=False, @@ -303,7 +286,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/circuits/circuit-types/", value_field="slug", ) ) @@ -312,7 +294,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/circuits/providers/", value_field="slug", ) ) @@ -326,7 +307,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -338,7 +318,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f2719eca6..f08ad4363 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -71,7 +71,6 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/regions/', value_field='slug', filter_for={ 'site': 'region' @@ -83,7 +82,6 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'device_id': 'site', @@ -93,10 +91,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelectMultiple( - api_url='/api/dcim/devices/', - ) + label='Device' ) @@ -327,10 +322,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants", - ) + required=False ) asn = forms.IntegerField( min_value=BGP_ASN_MIN, @@ -371,7 +363,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", ) ) @@ -384,10 +375,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( - queryset=Site.objects.all(), - widget=APISelect( - api_url="/api/dcim/sites/" - ) + queryset=Site.objects.all() ) slug = SlugField() @@ -423,7 +411,6 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -435,7 +422,6 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -475,7 +461,6 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'group': 'site_id', } @@ -483,17 +468,11 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/rack-groups/', - ) + required=False ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/rack-roles/', - ) + required=False ) comments = CommentField() tags = TagField( @@ -617,7 +596,6 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Site.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites", filter_for={ 'group': 'site_id', } @@ -625,17 +603,11 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/rack-groups", - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants", - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(RackStatusChoices), @@ -645,10 +617,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/rack-roles", - ) + required=False ) serial = forms.CharField( max_length=50, @@ -714,7 +683,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -726,7 +694,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'group_id': 'site' @@ -740,7 +707,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Rack group', widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", null_option=True ) ) @@ -754,7 +720,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/rack-roles/", value_field="slug", null_option=True, ) @@ -773,7 +738,6 @@ class RackElevationFilterForm(RackFilterForm): label='Rack', required=False, widget=APISelectMultiple( - api_url='/api/dcim/racks/', display_field='display_name', ) ) @@ -909,10 +873,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant", - ) + required=False ) description = forms.CharField( max_length=100, @@ -934,7 +895,6 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -943,7 +903,6 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): required=False, label='Rack group', widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", null_option=True, ) ) @@ -980,10 +939,7 @@ class ManufacturerCSVForm(forms.ModelForm): class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - widget=APISelect( - api_url="/api/dcim/manufacturers/", - ) + queryset=Manufacturer.objects.all() ) slug = SlugField( slug_source='model' @@ -1024,10 +980,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/manufacturers" - ) + required=False ) u_height = forms.IntegerField( min_value=1, @@ -1054,7 +1007,6 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/manufacturers/", value_field="slug", ) ) @@ -1126,10 +1078,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1169,10 +1118,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1212,10 +1158,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1285,10 +1228,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1354,10 +1294,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1417,10 +1354,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1511,10 +1445,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1560,10 +1491,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-types/' - ) + queryset=DeviceType.objects.all() ) name_pattern = ExpandableNameField( label='Name' @@ -1732,10 +1660,7 @@ class DeviceRoleCSVForm(forms.ModelForm): class PlatformForm(BootstrapMixin, forms.ModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/manufacturers/", - ) + required=False ) slug = SlugField( max_length=64 @@ -1779,7 +1704,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'rack': 'site_id' } @@ -1789,7 +1713,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Rack.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/racks/', display_field='display_name' ) ) @@ -1806,7 +1729,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Manufacturer.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/manufacturers/", filter_for={ 'device_type': 'manufacturer_id', 'platform': 'manufacturer_id' @@ -1816,21 +1738,16 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( - api_url='/api/dcim/device-types/', display_field='model' ) ) device_role = DynamicModelChoiceField( - queryset=DeviceRole.objects.all(), - widget=APISelect( - api_url='/api/dcim/device-roles/' - ) + queryset=DeviceRole.objects.all() ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/platforms/", additional_query_params={ "manufacturer_id": "null" } @@ -1840,7 +1757,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( - api_url="/api/virtualization/cluster-groups/", filter_for={ 'cluster': 'group_id' }, @@ -1851,10 +1767,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False, - widget=APISelect( - api_url='/api/virtualization/clusters/', - ) + required=False ) comments = CommentField() tags = TagField(required=False) @@ -2158,32 +2071,19 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/device-types/", - display_field='display_name' - ) + required=False ) device_role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/device-roles/" - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/platforms/" - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(DeviceStatusChoices), @@ -2217,7 +2117,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -2229,7 +2128,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'rack_group_id': 'site', @@ -2242,7 +2140,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Rack group', widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", filter_for={ 'rack_id': 'group_id', } @@ -2253,7 +2150,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Rack', widget=APISelectMultiple( - api_url="/api/dcim/racks/", null_option=True, ) ) @@ -2262,7 +2158,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/device-roles/", value_field="slug", ) ) @@ -2271,7 +2166,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Manufacturer', widget=APISelectMultiple( - api_url="/api/dcim/manufacturers/", filter_for={ 'device_type_id': 'manufacturer_id', } @@ -2282,7 +2176,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Model', widget=APISelectMultiple( - api_url="/api/dcim/device-types/", display_field="model", ) ) @@ -2291,7 +2184,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/platforms/", value_field="slug", null_option=True, ) @@ -2435,10 +2327,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class ConsolePortCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -2525,10 +2414,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -2629,10 +2515,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class PowerPortCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -2752,10 +2635,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class PowerOutletCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -2927,7 +2807,6 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): required=False, label='Untagged VLAN', widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -2940,7 +2819,6 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): required=False, label='Tagged VLANs', widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -2992,10 +2870,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -3045,7 +2920,6 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -3057,7 +2931,6 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -3204,7 +3077,6 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -3216,7 +3088,6 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -3318,10 +3189,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic class FrontPortCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -3501,10 +3369,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class RearPortCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -3590,7 +3455,6 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): label='Site', required=False, widget=APISelect( - api_url='/api/dcim/sites/', filter_for={ 'termination_b_rack': 'site_id', 'termination_b_device': 'site_id', @@ -3602,7 +3466,6 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): label='Rack', required=False, widget=APISelect( - api_url='/api/dcim/racks/', filter_for={ 'termination_b_device': 'rack_id', }, @@ -3616,7 +3479,6 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): label='Device', required=False, widget=APISelect( - api_url='/api/dcim/devices/', display_field='display_name', filter_for={ 'termination_b_id': 'device_id', @@ -3716,7 +3578,6 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): label='Provider', required=False, widget=APISelect( - api_url='/api/circuits/providers/', filter_for={ 'termination_b_circuit': 'provider_id', } @@ -3727,7 +3588,6 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): label='Site', required=False, widget=APISelect( - api_url='/api/dcim/sites/', filter_for={ 'termination_b_circuit': 'site_id', } @@ -3737,7 +3597,6 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): queryset=Circuit.objects.all(), label='Circuit', widget=APISelect( - api_url='/api/circuits/circuits/', display_field='cid', filter_for={ 'termination_b_id': 'circuit_id', @@ -3768,7 +3627,6 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): label='Site', required=False, widget=APISelect( - api_url='/api/dcim/sites/', display_field='cid', filter_for={ 'termination_b_rackgroup': 'site_id', @@ -3781,7 +3639,6 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): label='Rack Group', required=False, widget=APISelect( - api_url='/api/dcim/rack-groups/', display_field='cid', filter_for={ 'termination_b_powerpanel': 'rackgroup_id', @@ -3793,7 +3650,6 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): label='Power Panel', required=False, widget=APISelect( - api_url='/api/dcim/power-panels/', filter_for={ 'termination_b_id': 'power_panel_id', } @@ -4018,7 +3874,6 @@ class CableFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'rack_id': 'site', @@ -4031,7 +3886,6 @@ class CableFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", value_field='slug', filter_for={ 'device_id': 'tenant', @@ -4043,7 +3897,6 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Rack', widget=APISelectMultiple( - api_url="/api/dcim/racks/", null_option=True, filter_for={ 'device_id': 'rack_id', @@ -4068,10 +3921,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelectMultiple( - api_url='/api/dcim/devices/', - ) + label='Device' ) @@ -4101,10 +3951,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class DeviceBayCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' @@ -4202,7 +4049,6 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'device_id': 'site', @@ -4212,10 +4058,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelectMultiple( - api_url='/api/dcim/devices/', - ) + label='Device' ) @@ -4225,7 +4068,6 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'device_id': 'site', @@ -4235,10 +4077,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelectMultiple( - api_url='/api/dcim/devices/', - ) + label='Device' ) @@ -4248,7 +4087,6 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'device_id': 'site', @@ -4258,10 +4096,7 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelectMultiple( - api_url='/api/dcim/devices/', - ) + label='Device' ) @@ -4271,17 +4106,11 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): class InventoryItemForm(BootstrapMixin, forms.ModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/" - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/manufacturers/" - ) + required=False ) tags = TagField( required=False @@ -4296,20 +4125,14 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class InventoryItemCreateForm(BootstrapMixin, forms.Form): device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer'), - widget=APISelect( - api_url="/api/dcim/devices/", - ) + queryset=Device.objects.prefetch_related('device_type__manufacturer') ) name_pattern = ExpandableNameField( label='Name' ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/manufacturers/" - ) + required=False ) part_id = forms.CharField( max_length=50, @@ -4361,17 +4184,11 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): ) device = DynamicModelChoiceField( queryset=Device.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/devices/" - ) + required=False ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/manufacturers/" - ) + required=False ) part_id = forms.CharField( max_length=50, @@ -4400,7 +4217,6 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -4412,7 +4228,6 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'device_id': 'site' @@ -4422,17 +4237,13 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelect( - api_url='/api/dcim/devices/', - ) + label='Device' ) manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', required=False, widget=APISelect( - api_url="/api/dcim/manufacturers/", value_field="slug", ) ) @@ -4530,7 +4341,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): queryset=Site.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'rack': 'site_id', 'device': 'site_id', @@ -4541,7 +4351,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): queryset=Rack.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/racks/', filter_for={ 'device': 'rack_id' }, @@ -4555,7 +4364,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): virtual_chassis__isnull=True ), widget=APISelect( - api_url='/api/dcim/devices/', display_field='display_name', disabled_indicator='virtual_chassis' ) @@ -4581,7 +4389,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -4593,7 +4400,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -4602,7 +4408,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", null_option=True, filter_for={ @@ -4615,7 +4420,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", value_field="slug", null_option=True, ) @@ -4631,7 +4435,6 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'rack_group': 'site_id', } @@ -4639,10 +4442,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/rack-groups/', - ) + required=False ) class Meta: @@ -4696,7 +4496,6 @@ class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm): queryset=Site.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'rack_group': 'site_id', } @@ -4704,10 +4503,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm): ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/rack-groups/" - ) + required=False ) class Meta: @@ -4727,7 +4523,6 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -4739,7 +4534,6 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'rack_group_id': 'site', @@ -4751,7 +4545,6 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Rack group (ID)', widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", null_option=True, ) ) @@ -4766,7 +4559,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): queryset=Site.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/sites/', filter_for={ 'power_panel': 'site_id', 'rack': 'site_id', @@ -4774,17 +4566,11 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): ) ) power_panel = DynamicModelChoiceField( - queryset=PowerPanel.objects.all(), - widget=APISelect( - api_url="/api/dcim/power-panels/" - ) + queryset=PowerPanel.objects.all() ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/racks/" - ) + required=False ) comments = CommentField() tags = TagField( @@ -4900,7 +4686,6 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerPanel.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/power-panels/", filter_for={ 'rackgroup': 'site_id', } @@ -4908,10 +4693,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/racks", - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(PowerFeedStatusChoices), @@ -4968,7 +4750,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -4980,7 +4761,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'power_panel_id': 'site', @@ -4993,7 +4773,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Power panel', widget=APISelectMultiple( - api_url="/api/dcim/power-panels/", null_option=True, ) ) @@ -5002,7 +4781,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Rack', widget=APISelectMultiple( - api_url="/api/dcim/racks/", null_option=True, ) ) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index d6a5406b7..9f8b2968d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -198,60 +198,36 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelectMultiple( - api_url="/api/dcim/sites/" - ) + required=False ) roles = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), - required=False, - widget=APISelectMultiple( - api_url="/api/dcim/device-roles/" - ) + required=False ) platforms = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelectMultiple( - api_url="/api/dcim/platforms/" - ) + required=False ) cluster_groups = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), - required=False, - widget=APISelectMultiple( - api_url="/api/virtualization/cluster-groups/" - ) + required=False ) clusters = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), - required=False, - widget=APISelectMultiple( - api_url="/api/virtualization/clusters/" - ) + required=False ) tenant_groups = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) tenants = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/" - ) + required=False ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - api_url="/api/extras/tags/" - ) + required=False ) data = JSONField( label='' @@ -299,7 +275,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", ) ) @@ -308,7 +283,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -317,7 +291,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/device-roles/", value_field="slug", ) ) @@ -326,7 +299,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/platforms/", value_field="slug", ) ) @@ -335,24 +307,19 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/virtualization/cluster-groups/", value_field="slug", ) ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster', - widget=APISelectMultiple( - api_url="/api/virtualization/clusters/", - ) + label='Cluster' ) tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", ) ) @@ -361,7 +328,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", value_field="slug", ) ) @@ -370,7 +336,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/extras/tags/", value_field="slug", ) ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index f9c6fe515..9c2d1323e 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -78,10 +78,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) enforce_unique = forms.NullBooleanField( required=False, @@ -150,10 +147,7 @@ class RIRFilterForm(BootstrapMixin, forms.Form): class AggregateForm(BootstrapMixin, CustomFieldModelForm): rir = DynamicModelChoiceField( - queryset=RIR.objects.all(), - widget=APISelect( - api_url="/api/ipam/rirs/" - ) + queryset=RIR.objects.all() ) tags = TagField( required=False @@ -196,10 +190,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, - label='RIR', - widget=APISelect( - api_url="/api/ipam/rirs/" - ) + label='RIR' ) date_added = forms.DateField( required=False @@ -236,7 +227,6 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='RIR', widget=APISelectMultiple( - api_url="/api/ipam/rirs/", value_field="slug", ) ) @@ -276,16 +266,12 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - widget=APISelect( - api_url="/api/ipam/vrfs/", - ) + label='VRF' ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'vlan_group': 'site_id', 'vlan': 'site_id', @@ -300,7 +286,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False, label='VLAN group', widget=APISelect( - api_url='/api/ipam/vlan-groups/', filter_for={ 'vlan': 'group_id' }, @@ -314,16 +299,12 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False, label='VLAN', widget=APISelect( - api_url='/api/ipam/vlans/', display_field='display_name' ) ) role = DynamicModelChoiceField( queryset=Role.objects.all(), - required=False, - widget=APISelect( - api_url="/api/ipam/roles/" - ) + required=False ) tags = TagField(required=False) @@ -447,18 +428,12 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - widget=APISelect( - api_url="/api/ipam/vrfs/" - ) + label='VRF' ) prefix_length = forms.IntegerField( min_value=PREFIX_LENGTH_MIN, @@ -467,10 +442,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(PrefixStatusChoices), @@ -479,10 +451,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) role = DynamicModelChoiceField( queryset=Role.objects.all(), - required=False, - widget=APISelect( - api_url="/api/ipam/roles/" - ) + required=False ) is_pool = forms.NullBooleanField( required=False, @@ -536,7 +505,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, label='VRF', widget=APISelectMultiple( - api_url="/api/ipam/vrfs/", null_option=True, ) ) @@ -550,7 +518,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -562,7 +529,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) @@ -572,7 +538,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/ipam/roles/", value_field="slug", null_option=True, ) @@ -603,17 +568,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - widget=APISelect( - api_url="/api/ipam/vrfs/" - ) + label='VRF' ) nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, label='Site', widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'nat_rack': 'site_id', 'nat_device': 'site_id' @@ -625,7 +586,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel required=False, label='Rack', widget=APISelect( - api_url='/api/dcim/racks/', display_field='display_name', filter_for={ 'nat_device': 'rack_id' @@ -640,7 +600,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel required=False, label='Device', widget=APISelect( - api_url='/api/dcim/devices/', display_field='display_name', filter_for={ 'nat_inside': 'device_id' @@ -663,7 +622,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel required=False, label='IP Address', widget=APISelect( - api_url='/api/ipam/ip-addresses/', display_field='address' ) ) @@ -761,10 +719,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - widget=APISelect( - api_url="/api/ipam/vrfs/" - ) + label='VRF' ) class Meta: @@ -913,10 +868,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - widget=APISelect( - api_url="/api/ipam/vrfs/" - ) + label='VRF' ) mask_length = forms.IntegerField( min_value=IPADDRESS_MASK_LENGTH_MIN, @@ -925,10 +877,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(IPAddressStatusChoices), @@ -960,10 +909,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): queryset=VRF.objects.all(), required=False, label='VRF', - empty_label='Global', - widget=APISelect( - api_url="/api/ipam/vrfs/" - ) + empty_label='Global' ) q = forms.CharField( required=False, @@ -1007,7 +953,6 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo required=False, label='VRF', widget=APISelectMultiple( - api_url="/api/ipam/vrfs/", null_option=True, ) ) @@ -1038,10 +983,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo class VLANGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) slug = SlugField() @@ -1078,7 +1020,6 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region', @@ -1090,7 +1031,6 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) @@ -1106,7 +1046,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Site.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'group': 'site_id' }, @@ -1117,17 +1056,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - required=False, - widget=APISelect( - api_url='/api/ipam/vlan-groups/', - ) + required=False ) role = DynamicModelChoiceField( queryset=Role.objects.all(), - required=False, - widget=APISelect( - api_url="/api/ipam/roles/" - ) + required=False ) tags = TagField(required=False) @@ -1222,24 +1155,15 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/ipam/vlan-groups/" - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(VLANStatusChoices), @@ -1248,10 +1172,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) role = DynamicModelChoiceField( queryset=Role.objects.all(), - required=False, - widget=APISelect( - api_url="/api/ipam/roles/" - ) + required=False ) description = forms.CharField( max_length=100, @@ -1276,7 +1197,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region', @@ -1289,7 +1209,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) @@ -1299,7 +1218,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='VLAN group', widget=APISelectMultiple( - api_url="/api/ipam/vlan-groups/", null_option=True, ) ) @@ -1313,7 +1231,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/ipam/roles/", value_field="slug", null_option=True, ) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index a8db8124b..03ff8fab8 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -72,10 +72,7 @@ class SecretRoleCSVForm(forms.ModelForm): class SecretForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.all(), - widget=APISelect( - api_url="/api/dcim/devices/" - ) + queryset=Device.objects.all() ) plaintext = forms.CharField( max_length=SECRET_PLAINTEXT_MAX_LENGTH, @@ -94,10 +91,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): widget=forms.PasswordInput() ) role = DynamicModelChoiceField( - queryset=SecretRole.objects.all(), - widget=APISelect( - api_url="/api/secrets/secret-roles/" - ) + queryset=SecretRole.objects.all() ) tags = TagField( required=False @@ -166,10 +160,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) role = DynamicModelChoiceField( queryset=SecretRole.objects.all(), - required=False, - widget=APISelect( - api_url="/api/secrets/secret-roles/" - ) + required=False ) name = forms.CharField( max_length=100, @@ -193,7 +184,6 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/secrets/secret-roles/", value_field="slug", ) ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 5b828b661..1e6fcaba5 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -44,10 +44,7 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) comments = CommentField() tags = TagField( @@ -89,10 +86,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) class Meta: @@ -112,7 +106,6 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", null_option=True, ) @@ -129,7 +122,6 @@ class TenancyForm(forms.Form): queryset=TenantGroup.objects.all(), required=False, widget=APISelect( - api_url="/api/tenancy/tenant-groups/", filter_for={ 'tenant': 'group_id', }, @@ -140,10 +132,7 @@ class TenancyForm(forms.Form): ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url='/api/tenancy/tenants/' - ) + required=False ) def __init__(self, *args, **kwargs): @@ -164,7 +153,6 @@ class TenancyFilterForm(forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", null_option=True, filter_for={ @@ -177,7 +165,6 @@ class TenancyFilterForm(forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", value_field="slug", null_option=True, ) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 420dbf03b..c17ff9299 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -253,7 +253,7 @@ class APISelect(SelectWithDisabled): """ A select widget populated via an API call - :param api_url: API URL + :param api_url: API endpoint URL. Required if not set automatically by the parent field. :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 0dbe38324..490f3d63f 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -9,7 +9,7 @@ from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, S from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) -from ipam.models import IPAddress, VLANGroup, VLAN +from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -77,24 +77,15 @@ class ClusterGroupCSVForm(forms.ModelForm): class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): type = DynamicModelChoiceField( - queryset=ClusterType.objects.all(), - widget=APISelect( - api_url="/api/virtualization/cluster-types/" - ) + queryset=ClusterType.objects.all() ) group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-groups/" - ) + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) comments = CommentField() tags = TagField( @@ -157,31 +148,19 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-types/" - ) + required=False ) group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-groups/" - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) comments = CommentField( widget=SmallTextarea, @@ -205,7 +184,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/virtualization/cluster-types/", value_field='slug', ) ) @@ -214,7 +192,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -226,7 +203,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field='slug', null_option=True, ) @@ -236,7 +212,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/virtualization/cluster-groups/", value_field='slug', null_option=True, ) @@ -249,7 +224,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Region.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/regions/", filter_for={ "site": "region_id", }, @@ -262,7 +236,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Site.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/sites/', filter_for={ "rack": "site_id", "devices": "site_id", @@ -273,7 +246,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Rack.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/racks/', filter_for={ "devices": "rack_id" }, @@ -285,7 +257,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): devices = DynamicModelMultipleChoiceField( queryset=Device.objects.filter(cluster__isnull=True), widget=APISelectMultiple( - api_url='/api/dcim/devices/', display_field='display_name', disabled_indicator='cluster' ) @@ -334,7 +305,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( - api_url='/api/virtualization/cluster-groups/', filter_for={ "cluster": "group_id", }, @@ -344,16 +314,12 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) ) cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - widget=APISelect( - api_url='/api/virtualization/clusters/' - ) + queryset=Cluster.objects.all() ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/device-roles/", additional_query_params={ "vm_role": "True" } @@ -361,10 +327,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/platforms/' - ) + required=False ) tags = TagField( required=False @@ -499,10 +462,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False, - widget=APISelect( - api_url='/api/virtualization/clusters/' - ) + required=False ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( @@ -510,7 +470,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ), required=False, widget=APISelect( - api_url="/api/dcim/device-roles/", additional_query_params={ "vm_role": "True" } @@ -518,17 +477,11 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url='/api/tenancy/tenants/' - ) + required=False ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/platforms/' - ) + required=False ) vcpus = forms.IntegerField( required=False, @@ -568,7 +521,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/virtualization/cluster-groups/', value_field="slug", null_option=True, ) @@ -578,7 +530,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/virtualization/cluster-types/', value_field="slug", null_option=True, ) @@ -586,17 +537,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster', - widget=APISelectMultiple( - api_url='/api/virtualization/clusters/', - ) + label='Cluster' ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/regions/', value_field="slug", filter_for={ 'site': 'region' @@ -608,7 +555,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/sites/', value_field="slug", null_option=True, ) @@ -618,7 +564,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/device-roles/', value_field="slug", null_option=True, additional_query_params={ @@ -636,7 +581,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/platforms/', value_field="slug", null_option=True, ) @@ -657,7 +601,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -669,7 +612,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -766,7 +708,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -778,7 +719,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -836,7 +776,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -848,7 +787,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ From 73881ad1e043850d5462b5e1ca81dce410b4c10c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Mar 2020 14:24:17 -0400 Subject: [PATCH 33/66] Change CircuitTerminationForm.site to a DynamicModelChoiceField --- netbox/circuits/forms.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index bb685b402..c6f0dfdc4 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -334,6 +334,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) class Meta: model = CircuitTermination @@ -347,7 +350,4 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): } widgets = { 'term_side': forms.HiddenInput(), - 'site': APISelect( - api_url="/api/dcim/sites/" - ) } From 450615e0bb0cbec80213fa0e7011be316f39f03a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Mar 2020 14:29:01 -0400 Subject: [PATCH 34/66] Change IPAddressForm.nat_vrf to DynamicModelChoiceField --- netbox/ipam/forms.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 9c2d1323e..7a4373fb0 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -606,12 +606,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_vrf = forms.ModelChoiceField( + nat_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', widget=APISelect( - api_url="/api/ipam/vrfs/", filter_for={ 'nat_inside': 'vrf_id' } From 3590ed378d161dd724fad2dc73ff56da746352f8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 10:22:56 -0400 Subject: [PATCH 35/66] Rename 'webhooks' REDIS config to 'tasks' --- docs/configuration/required-settings.md | 16 +++--- docs/installation/3-netbox.md | 2 +- netbox/netbox/configuration.example.py | 8 +-- netbox/netbox/settings.py | 75 ++++++++++++++----------- 4 files changed, 55 insertions(+), 46 deletions(-) diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index e86b2810a..053e2d3d4 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -46,9 +46,9 @@ DATABASE = { [Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching functionality (as well as other planned features). In 2.7, the connection settings were broken down into two sections for -webhooks and caching, allowing the user to connect to different Redis instances/databases per feature. +task queuing and caching, allowing the user to connect to different Redis instances/databases per feature. -Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `webhooks` and `caching` subsections: +Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `tasks` and `caching` subsections: * `HOST` - Name or IP address of the Redis server (use `localhost` if running locally) * `PORT` - TCP port of the Redis service; leave blank for default port (6379) @@ -61,7 +61,7 @@ Example: ```python REDIS = { - 'webhooks': { + 'tasks': { 'HOST': 'redis.example.com', 'PORT': 1234, 'PASSWORD': 'foobar', @@ -84,9 +84,9 @@ REDIS = { If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary -!!! note - It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the - same Redis instance for both may result in webhook processing data being lost during cache flushing events. +!!! warning + It is highly recommended to keep the task and cache databases separate. Using the same database number on the + same Redis instance for both may result in queued background tasks being lost during cache flushing events. ### Using Redis Sentinel @@ -102,7 +102,7 @@ Example: ```python REDIS = { - 'webhooks': { + 'tasks': { 'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', @@ -126,7 +126,7 @@ REDIS = { !!! note It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible - for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via + for example to have the tasks database use sentinel via `HOST`/`PORT` and for caching to use Sentinel via `SENTINELS`/`SENTINEL_SERVICE`. diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index fabad20eb..b9b68be1b 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -172,7 +172,7 @@ Redis is a in-memory key-value store required as part of the NetBox installation ```python REDIS = { - 'webhooks': { + 'tasks': { 'HOST': 'redis.example.com', 'PORT': 1234, 'PASSWORD': 'foobar', diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 136c7c828..be4b4d762 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -21,11 +21,11 @@ DATABASE = { 'CONN_MAX_AGE': 300, # Max database connection age } -# Redis database settings. The Redis database is used for caching and background processing such as webhooks -# Seperate sections for webhooks and caching allow for connecting to seperate Redis instances/datbases if desired. -# Full connection details are required in both sections, even if they are the same. +# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate +# configuration exists for each. Full connection details are required in both sections, and it is strongly recommended +# to use two separate database IDs. REDIS = { - 'webhooks': { + 'tasks': { 'HOST': 'localhost', 'PORT': 6379, # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c954c6a11..fe7932705 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -178,31 +178,40 @@ if STORAGE_CONFIG and STORAGE_BACKEND is None: # Redis # -if 'webhooks' not in REDIS: - raise ImproperlyConfigured( - "REDIS section in configuration.py is missing webhooks subsection." +# Background task queuing +if 'tasks' in REDIS: + TASKS_REDIS = REDIS['tasks'] +elif 'webhooks' in REDIS: + # TODO: Remove support for 'webhooks' name in v2.9 + warnings.warn( + "The 'webhooks' REDIS configuration section has been renamed to 'tasks'. Please update your configuration as " + "support for the old name will be removed in a future release." ) -if 'caching' not in REDIS: + TASKS_REDIS = REDIS['webhooks'] +else: + raise ImproperlyConfigured( + "REDIS section in configuration.py is missing the 'tasks' subsection." + ) +TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost') +TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379) +TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', []) +TASKS_REDIS_USING_SENTINEL = all([ + isinstance(TASKS_REDIS_SENTINELS, (list, tuple)), + len(TASKS_REDIS_SENTINELS) > 0 +]) +TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default') +TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '') +TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) +TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300) +TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) + +# Caching +if 'caching' in REDIS: + CACHING_REDIS = REDIS['caching'] +else: raise ImproperlyConfigured( "REDIS section in configuration.py is missing caching subsection." ) - -WEBHOOKS_REDIS = REDIS.get('webhooks', {}) -WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') -WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) -WEBHOOKS_REDIS_SENTINELS = WEBHOOKS_REDIS.get('SENTINELS', []) -WEBHOOKS_REDIS_USING_SENTINEL = all([ - isinstance(WEBHOOKS_REDIS_SENTINELS, (list, tuple)), - len(WEBHOOKS_REDIS_SENTINELS) > 0 -]) -WEBHOOKS_REDIS_SENTINEL_SERVICE = WEBHOOKS_REDIS.get('SENTINEL_SERVICE', 'default') -WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') -WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) -WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) -WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) - - -CACHING_REDIS = REDIS.get('caching', {}) CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost') CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379) CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', []) @@ -569,20 +578,20 @@ SWAGGER_SETTINGS = { RQ_QUEUES = { 'default': { - 'HOST': WEBHOOKS_REDIS_HOST, - 'PORT': WEBHOOKS_REDIS_PORT, - 'DB': WEBHOOKS_REDIS_DATABASE, - 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, - 'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT, - 'SSL': WEBHOOKS_REDIS_SSL, - } if not WEBHOOKS_REDIS_USING_SENTINEL else { - 'SENTINELS': WEBHOOKS_REDIS_SENTINELS, - 'MASTER_NAME': WEBHOOKS_REDIS_SENTINEL_SERVICE, - 'DB': WEBHOOKS_REDIS_DATABASE, - 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, + 'HOST': TASKS_REDIS_HOST, + 'PORT': TASKS_REDIS_PORT, + 'DB': TASKS_REDIS_DATABASE, + 'PASSWORD': TASKS_REDIS_PASSWORD, + 'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT, + 'SSL': TASKS_REDIS_SSL, + } if not TASKS_REDIS_USING_SENTINEL else { + 'SENTINELS': TASKS_REDIS_SENTINELS, + 'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE, + 'DB': TASKS_REDIS_DATABASE, + 'PASSWORD': TASKS_REDIS_PASSWORD, 'SOCKET_TIMEOUT': None, 'CONNECTION_KWARGS': { - 'socket_connect_timeout': WEBHOOKS_REDIS_DEFAULT_TIMEOUT + 'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT }, } } From a2eb2e7da6302446f7b33499a85dbc4fab1526bf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 11:22:56 -0400 Subject: [PATCH 36/66] Introduce a new 'check_releases' RQ queue --- netbox/extras/management/commands/rqworker.py | 16 ++++++++++++++++ netbox/netbox/settings.py | 15 ++++++++++----- netbox/utilities/background_tasks.py | 2 +- 3 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 netbox/extras/management/commands/rqworker.py diff --git a/netbox/extras/management/commands/rqworker.py b/netbox/extras/management/commands/rqworker.py new file mode 100644 index 000000000..02e93c4ef --- /dev/null +++ b/netbox/extras/management/commands/rqworker.py @@ -0,0 +1,16 @@ +from django.conf import settings +from django_rq.management.commands.rqworker import Command as _Command + + +class Command(_Command): + """ + Subclass django_rq's built-in rqworker to listen on all configured queues if none are specified (instead + of only the 'default' queue). + """ + def handle(self, *args, **options): + + # If no queues have been specified on the command line, listen on all configured queues. + if len(args) < 1: + args = settings.RQ_QUEUES + + super().handle(*args, **options) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index fe7932705..2f9643c78 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -266,7 +266,6 @@ INSTALLED_APPS = [ 'corsheaders', 'debug_toolbar', 'django_filters', - 'django_rq', 'django_tables2', 'django_prometheus', 'mptt', @@ -283,6 +282,7 @@ INSTALLED_APPS = [ 'users', 'utilities', 'virtualization', + 'django_rq', # Must come after extras to allow overriding management commands 'drf_yasg', ] @@ -576,15 +576,17 @@ SWAGGER_SETTINGS = { # Django RQ (Webhooks backend) # -RQ_QUEUES = { - 'default': { +if not TASKS_REDIS_USING_SENTINEL: + RQ_PARAMS = { 'HOST': TASKS_REDIS_HOST, 'PORT': TASKS_REDIS_PORT, 'DB': TASKS_REDIS_DATABASE, 'PASSWORD': TASKS_REDIS_PASSWORD, 'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT, 'SSL': TASKS_REDIS_SSL, - } if not TASKS_REDIS_USING_SENTINEL else { + } +else: + RQ_PARAMS = { 'SENTINELS': TASKS_REDIS_SENTINELS, 'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE, 'DB': TASKS_REDIS_DATABASE, @@ -594,8 +596,11 @@ RQ_QUEUES = { 'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT }, } -} +RQ_QUEUES = { + 'default': RQ_PARAMS, # Webhooks + 'check_releases': RQ_PARAMS, +} # # Django debug toolbar diff --git a/netbox/utilities/background_tasks.py b/netbox/utilities/background_tasks.py index a2c1310c7..c1604d900 100644 --- a/netbox/utilities/background_tasks.py +++ b/netbox/utilities/background_tasks.py @@ -10,7 +10,7 @@ from packaging import version logger = logging.getLogger('netbox.releases') -@job +@job('check_releases') def get_releases(pre_releases=False): url = settings.UPDATE_REPO_URL headers = { From ec2dc8d7a4336904b8de0323c6026479feff41bd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 11:28:35 -0400 Subject: [PATCH 37/66] Fix template logic --- netbox/templates/home.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/home.html b/netbox/templates/home.html index f846f7f59..77a559e77 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -3,13 +3,13 @@ {% block header %} {{ block.super }} - {% if new_release and request.user.is_staff or request.user.is_superuser %} + {% if new_release %}{% if request.user.is_staff or request.user.is_superuser %} - {% endif %} + {% endif %}{% endif %} {% endblock %} From ab93606e4ac19d32459d22daf9592dd6d48eab3a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 11:37:35 -0400 Subject: [PATCH 38/66] Check for an existing job before queuing a new one --- netbox/utilities/releases.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/releases.py b/netbox/utilities/releases.py index f557cc433..6ce5b9ed0 100644 --- a/netbox/utilities/releases.py +++ b/netbox/utilities/releases.py @@ -2,6 +2,7 @@ import logging from cacheops import CacheMiss, cache from django.conf import settings +from django_rq import get_queue from utilities.background_tasks import get_releases @@ -17,9 +18,14 @@ def get_latest_release(pre_releases=False): logger.debug("Found {} cached releases. Latest: {}".format(len(releases), max(releases))) return max(releases) except CacheMiss: - # Get the releases in the background worker, it will fill the cache - logger.debug("Initiating background task to retrieve updated releases list") - get_releases.delay(pre_releases=pre_releases) + # Check for an existing job. This can happen if the RQ worker process is not running. + queue = get_queue('check_releases') + if queue.jobs: + logger.debug("Job to check for new releases is already queued; skipping") + else: + # Get the releases in the background worker, it will fill the cache + logger.debug("Initiating background task to retrieve updated releases list") + get_releases.delay(pre_releases=pre_releases) else: logger.debug("Skipping release check; UPDATE_REPO_URL not defined") From 356de985d2f343d42e4108316468bc4b26dd97f3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 11:49:45 -0400 Subject: [PATCH 39/66] Tweak release template variables --- netbox/netbox/views.py | 13 ++++++------- netbox/templates/home.html | 7 ++++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 541757742..2741cf16d 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -243,17 +243,17 @@ class HomeView(View): } + # Check whether a new release is available. (Only for staff/superusers.) new_release = None - new_release_url = None - if request.user.is_staff or request.user.is_superuser: - # Only check for new releases if the current user might be able to do anything about it - latest_release, github_url = get_latest_release() + latest_release, release_url = get_latest_release() if isinstance(latest_release, version.Version): current_version = version.parse(settings.VERSION) if latest_release > current_version: - new_release = str(latest_release) - new_release_url = github_url + new_release = { + 'version': str(latest_release), + 'url': release_url, + } return render(request, self.template_name, { 'search_form': SearchForm(), @@ -261,7 +261,6 @@ class HomeView(View): 'report_results': ReportResult.objects.order_by('-created')[:10], 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15], 'new_release': new_release, - 'new_release_url': new_release_url, }) diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 77a559e77..d3885b88f 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -3,13 +3,14 @@ {% block header %} {{ block.super }} - {% if new_release %}{% if request.user.is_staff or request.user.is_superuser %} + {% if new_release %} + {# new_release is set only if the current user is a superuser or staff member #} - {% endif %}{% endif %} + {% endif %} {% endblock %} From 3ace83c5aad525d167568854fde938e46a7554f4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 11:58:17 -0400 Subject: [PATCH 40/66] Cache only the most recent NetBox release --- netbox/netbox/tests/test_get_releases.py | 6 +++--- netbox/utilities/background_tasks.py | 8 ++++---- netbox/utilities/releases.py | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/netbox/netbox/tests/test_get_releases.py b/netbox/netbox/tests/test_get_releases.py index 1fc709b03..0b1f4eb58 100644 --- a/netbox/netbox/tests/test_get_releases.py +++ b/netbox/netbox/tests/test_get_releases.py @@ -83,7 +83,7 @@ class GetReleasesTestCase(SimpleTestCase): # Check if result is put in cache dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('netbox_releases', releases, 160876) + dummy_cache_set.assert_called_with('latest_release', releases, 160876) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') @@ -109,7 +109,7 @@ class GetReleasesTestCase(SimpleTestCase): # Check if result is put in cache dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('netbox_releases', releases, 160876) + dummy_cache_set.assert_called_with('latest_release', releases, 160876) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') @@ -139,7 +139,7 @@ class GetReleasesTestCase(SimpleTestCase): # Check if failure is put in cache dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('netbox_releases_no_retry', 'https://localhost/unittest/releases', 900) + dummy_cache_set.assert_called_with('latest_release_no_retry', 'https://localhost/unittest/releases', 900) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') diff --git a/netbox/utilities/background_tasks.py b/netbox/utilities/background_tasks.py index c1604d900..29f9e8f3e 100644 --- a/netbox/utilities/background_tasks.py +++ b/netbox/utilities/background_tasks.py @@ -19,7 +19,7 @@ def get_releases(pre_releases=False): # Check whether this URL has failed and shouldn't be retried yet try: - failed_url = cache.get('netbox_releases_no_retry') + failed_url = cache.get('latest_release_no_retry') if url == failed_url: return [] except CacheMiss: @@ -44,10 +44,10 @@ def get_releases(pre_releases=False): except Exception: # Don't retry this URL for 15 minutes logger.exception("Error while fetching {}".format(url)) - cache.set('netbox_releases_no_retry', url, 900) + cache.set('latest_release_no_retry', url, 900) return [] - # Cache the releases list - cache.set('netbox_releases', releases, settings.UPDATE_CACHE_TIMEOUT) + # Cache the most recent release + cache.set('latest_release', max(releases), settings.UPDATE_CACHE_TIMEOUT) return releases diff --git a/netbox/utilities/releases.py b/netbox/utilities/releases.py index 6ce5b9ed0..841cc8b0c 100644 --- a/netbox/utilities/releases.py +++ b/netbox/utilities/releases.py @@ -13,10 +13,10 @@ def get_latest_release(pre_releases=False): if settings.UPDATE_REPO_URL: logger.debug("Checking for most recent release") try: - releases = cache.get('netbox_releases') - if releases: - logger.debug("Found {} cached releases. Latest: {}".format(len(releases), max(releases))) - return max(releases) + latest_release = cache.get('latest_release') + if latest_release: + logger.debug("Found cached release: {}".format(latest_release)) + return latest_release except CacheMiss: # Check for an existing job. This can happen if the RQ worker process is not running. queue = get_queue('check_releases') From 2f12d09663567498839e7e03718337dff6407bdf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 12:14:17 -0400 Subject: [PATCH 41/66] Clean up URL damping --- netbox/utilities/background_tasks.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/netbox/utilities/background_tasks.py b/netbox/utilities/background_tasks.py index 29f9e8f3e..68eebbcc8 100644 --- a/netbox/utilities/background_tasks.py +++ b/netbox/utilities/background_tasks.py @@ -16,17 +16,16 @@ def get_releases(pre_releases=False): headers = { 'Accept': 'application/vnd.github.v3+json', } + releases = [] - # Check whether this URL has failed and shouldn't be retried yet + # Check whether this URL has failed recently and shouldn't be retried yet try: - failed_url = cache.get('latest_release_no_retry') - if url == failed_url: + if url == cache.get('latest_release_no_retry'): + logger.debug("Skipping release check; URL failed recently: {}".format(url)) return [] except CacheMiss: pass - releases = [] - try: logger.debug("Fetching new releases from {}".format(url)) response = requests.get(url, headers=headers) @@ -41,9 +40,9 @@ def get_releases(pre_releases=False): releases.append((version.parse(release['tag_name']), release.get('html_url'))) logger.debug("Found {} releases; {} usable".format(total_releases, len(releases))) - except Exception: - # Don't retry this URL for 15 minutes - logger.exception("Error while fetching {}".format(url)) + except requests.exceptions.RequestException: + # The request failed. Set a flag in the cache to disable future checks to this URL for 15 minutes. + logger.exception("Error while fetching {}. Disabling checks for 15 minutes.".format(url)) cache.set('latest_release_no_retry', url, 900) return [] From 81287833eec6921c8f8eff66875cd3ac7ee746bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 14:44:49 -0400 Subject: [PATCH 42/66] Update tests --- netbox/netbox/tests/test_get_releases.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/tests/test_get_releases.py b/netbox/netbox/tests/test_get_releases.py index 0b1f4eb58..0f6d73c5e 100644 --- a/netbox/netbox/tests/test_get_releases.py +++ b/netbox/netbox/tests/test_get_releases.py @@ -56,7 +56,7 @@ def unsuccessful_github_response(url, *_args, **_kwargs): return r -@override_settings(UPDATE_REPO_URL='https://localhost/unittest', UPDATE_CACHE_TIMEOUT=160876) +@override_settings(UPDATE_REPO_URL='https://localhost/unittest/releases', UPDATE_CACHE_TIMEOUT=160876) class GetReleasesTestCase(SimpleTestCase): @patch.object(requests, 'get') @patch.object(RedisCache, 'set') @@ -83,7 +83,7 @@ class GetReleasesTestCase(SimpleTestCase): # Check if result is put in cache dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('latest_release', releases, 160876) + dummy_cache_set.assert_called_with('latest_release', max(releases), 160876) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') @@ -109,7 +109,7 @@ class GetReleasesTestCase(SimpleTestCase): # Check if result is put in cache dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('latest_release', releases, 160876) + dummy_cache_set.assert_called_with('latest_release', max(releases), 160876) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') From 1706db38df2a51e4555a178132ce7dc6c454aef6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 15:00:39 -0400 Subject: [PATCH 43/66] Fix Python 3.5 compatability for tests --- netbox/netbox/tests/test_get_releases.py | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/netbox/netbox/tests/test_get_releases.py b/netbox/netbox/tests/test_get_releases.py index 0f6d73c5e..801f5579b 100644 --- a/netbox/netbox/tests/test_get_releases.py +++ b/netbox/netbox/tests/test_get_releases.py @@ -75,11 +75,10 @@ class GetReleasesTestCase(SimpleTestCase): ]) # Check if correct request is made - dummy_request_get.assert_called_once() - dummy_request_get.assert_called_with('https://localhost/unittest/releases', - headers={ - 'Accept': 'application/vnd.github.v3+json' - }) + dummy_request_get.assert_called_once_with( + 'https://localhost/unittest/releases', + headers={'Accept': 'application/vnd.github.v3+json'} + ) # Check if result is put in cache dummy_cache_set.assert_called_once() @@ -101,11 +100,10 @@ class GetReleasesTestCase(SimpleTestCase): ]) # Check if correct request is made - dummy_request_get.assert_called_once() - dummy_request_get.assert_called_with('https://localhost/unittest/releases', - headers={ - 'Accept': 'application/vnd.github.v3+json' - }) + dummy_request_get.assert_called_once_with( + 'https://localhost/unittest/releases', + headers={'Accept': 'application/vnd.github.v3+json'} + ) # Check if result is put in cache dummy_cache_set.assert_called_once() @@ -131,11 +129,10 @@ class GetReleasesTestCase(SimpleTestCase): self.assertListEqual(releases, []) # Check if correct request is made - dummy_request_get.assert_called_once() - dummy_request_get.assert_called_with('https://localhost/unittest/releases', - headers={ - 'Accept': 'application/vnd.github.v3+json' - }) + dummy_request_get.assert_called_once_with( + 'https://localhost/unittest/releases', + headers={'Accept': 'application/vnd.github.v3+json'} + ) # Check if failure is put in cache dummy_cache_set.assert_called_once() From 022653f44678a64e521097a86125be3acac7477b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Mar 2020 15:07:45 -0400 Subject: [PATCH 44/66] Fix Python 3.5 compatability for tests --- netbox/netbox/tests/test_get_releases.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/tests/test_get_releases.py b/netbox/netbox/tests/test_get_releases.py index 801f5579b..8d75e4dfc 100644 --- a/netbox/netbox/tests/test_get_releases.py +++ b/netbox/netbox/tests/test_get_releases.py @@ -81,8 +81,11 @@ class GetReleasesTestCase(SimpleTestCase): ) # Check if result is put in cache - dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('latest_release', max(releases), 160876) + dummy_cache_set.assert_called_once_with( + 'latest_release', + max(releases), + 160876 + ) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') @@ -106,8 +109,11 @@ class GetReleasesTestCase(SimpleTestCase): ) # Check if result is put in cache - dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('latest_release', max(releases), 160876) + dummy_cache_set.assert_called_once_with( + 'latest_release', + max(releases), + 160876 + ) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') @@ -135,8 +141,11 @@ class GetReleasesTestCase(SimpleTestCase): ) # Check if failure is put in cache - dummy_cache_set.assert_called_once() - dummy_cache_set.assert_called_with('latest_release_no_retry', 'https://localhost/unittest/releases', 900) + dummy_cache_set.assert_called_once_with( + 'latest_release_no_retry', + 'https://localhost/unittest/releases', + 900 + ) @patch.object(requests, 'get') @patch.object(RedisCache, 'set') From 043b1c28d2fe7c4b8f44d0f10e43388132e50c8f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 12:00:31 -0400 Subject: [PATCH 45/66] Refactor the registry into a dictionary object --- netbox/extras/registry.py | 21 ++++++++++++++++ netbox/extras/tests/test_registry.py | 33 +++++++++++++++++++++++++ netbox/extras/utils.py | 36 +++++++--------------------- 3 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 netbox/extras/registry.py create mode 100644 netbox/extras/tests/test_registry.py diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py new file mode 100644 index 000000000..47eaeb3b7 --- /dev/null +++ b/netbox/extras/registry.py @@ -0,0 +1,21 @@ +class Registry(dict): + """ + Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or + deleted (although its value may be manipulated). + """ + def __getitem__(self, key): + try: + return super().__getitem__(key) + except KeyError: + raise Exception("Invalid store: {}".format(key)) + + def __setitem__(self, key, value): + if key in self: + raise Exception("Store already set: {}".format(key)) + super().__setitem__(key, value) + + def __delitem__(self, key): + raise Exception("Cannot delete stores from registry") + + +registry = Registry() diff --git a/netbox/extras/tests/test_registry.py b/netbox/extras/tests/test_registry.py new file mode 100644 index 000000000..394b7a9f3 --- /dev/null +++ b/netbox/extras/tests/test_registry.py @@ -0,0 +1,33 @@ +from django.test import TestCase + +from extras.registry import Registry + + +class RegistryTest(TestCase): + + def test_add_store(self): + reg = Registry() + reg['foo'] = 123 + + self.assertEqual(reg['foo'], 123) + + def test_manipulate_store(self): + reg = Registry() + reg['foo'] = [1, 2] + reg['foo'].append(3) + + self.assertListEqual(reg['foo'], [1, 2, 3]) + + def test_overwrite_store(self): + reg = Registry() + reg['foo'] = 123 + + with self.assertRaises(Exception): + reg['foo'] = 456 + + def test_delete_store(self): + reg = Registry() + reg['foo'] = 123 + + with self.assertRaises(Exception): + del(reg['foo']) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 5ae16bff9..78214fe41 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -6,6 +6,7 @@ from taggit.managers import _TaggableManager from utilities.querysets import DummyQuerySet from extras.constants import EXTRAS_FEATURES +from extras.registry import registry def is_taggable(obj): @@ -21,33 +22,12 @@ def is_taggable(obj): return False -# -# Dynamic feature registration -# - -class Registry: - """ - The registry is a place to hook into for data storage across components - """ - - def add_store(self, store_name, initial_value=None): - """ - Given the name of some new data parameter and an optional initial value, setup the registry store - """ - if not hasattr(Registry, store_name): - setattr(Registry, store_name, initial_value) - - -registry = Registry() - - @deconstructible class FeatureQuery: """ - Helper class that delays evaluation of the registry contents for the functionaility store + Helper class that delays evaluation of the registry contents for the functionality store until it has been populated. """ - def __init__(self, feature): self.feature = feature @@ -59,24 +39,26 @@ class FeatureQuery: Given an extras feature, return a Q object for content type lookup """ query = Q() - for app_label, models in registry.model_feature_store[self.feature].items(): + for app_label, models in registry['model_features'][self.feature].items(): query |= Q(app_label=app_label, model__in=models) return query -registry.add_store('model_feature_store', {f: collections.defaultdict(list) for f in EXTRAS_FEATURES}) - - def extras_features(*features): """ Decorator used to register extras provided features to a model """ def wrapper(model_class): + # Initialize the model_features store if not already defined + if 'model_features' not in registry: + registry['model_features'] = { + f: collections.defaultdict(list) for f in EXTRAS_FEATURES + } for feature in features: if feature in EXTRAS_FEATURES: app_label, model_name = model_class._meta.label_lower.split('.') - registry.model_feature_store[feature][app_label].append(model_name) + registry['model_features'][feature][app_label].append(model_name) else: raise ValueError('{} is not a valid extras feature!'.format(feature)) return model_class From 70c29051b39f0ec849e48b292034677ac5097c0e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 13:15:22 -0400 Subject: [PATCH 46/66] Raise specific exceptions --- netbox/extras/registry.py | 6 +++--- netbox/extras/tests/test_registry.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index 47eaeb3b7..cb58f5135 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -7,15 +7,15 @@ class Registry(dict): try: return super().__getitem__(key) except KeyError: - raise Exception("Invalid store: {}".format(key)) + raise KeyError("Invalid store: {}".format(key)) def __setitem__(self, key, value): if key in self: - raise Exception("Store already set: {}".format(key)) + raise KeyError("Store already set: {}".format(key)) super().__setitem__(key, value) def __delitem__(self, key): - raise Exception("Cannot delete stores from registry") + raise TypeError("Cannot delete stores from registry") registry = Registry() diff --git a/netbox/extras/tests/test_registry.py b/netbox/extras/tests/test_registry.py index 394b7a9f3..53ba6584a 100644 --- a/netbox/extras/tests/test_registry.py +++ b/netbox/extras/tests/test_registry.py @@ -22,12 +22,12 @@ class RegistryTest(TestCase): reg = Registry() reg['foo'] = 123 - with self.assertRaises(Exception): + with self.assertRaises(KeyError): reg['foo'] = 456 def test_delete_store(self): reg = Registry() reg['foo'] = 123 - with self.assertRaises(Exception): + with self.assertRaises(TypeError): del(reg['foo']) From fe4f4bddc8392a46daddbcd4b6d2350f4846e699 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 13:46:47 -0400 Subject: [PATCH 47/66] Tweaked logging; renamed release config parameters --- docs/configuration/optional-settings.md | 36 ++++++++++++------------ netbox/netbox/configuration.example.py | 16 +++++------ netbox/netbox/settings.py | 34 +++++++++++----------- netbox/netbox/tests/test_get_releases.py | 2 +- netbox/utilities/background_tasks.py | 6 ++-- netbox/utilities/releases.py | 8 +++--- 6 files changed, 51 insertions(+), 51 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 2a58ad807..83a56d2c4 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -299,6 +299,24 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## RELEASE_CHECK_TIMEOUT + +Default: 86,400 (24 hours) + +The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. This must be set to at least one hour (3600 seconds). + +--- + +## RELEASE_CHECK_URL + +Default: None + +The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. The URL provided **must** be compatible with the GitHub API. + +Use `'https://api.github.com/repos/netbox-community/netbox/releases'` to check for release in the official NetBox repository. + +--- + ## REPORTS_ROOT Default: $BASE_DIR/netbox/reports/ @@ -351,24 +369,6 @@ The time zone NetBox will use when dealing with dates and times. It is recommend --- -## UPDATE_CACHE_TIMEOUT - -Default: 86,400 (24 hours) - -The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. This must be set to at least one hour (3600 seconds). - ---- - -## UPDATE_REPO_URL - -Default: None - -The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. The URL provided **must** be compatible with the GitHub API. - -Use `'https://api.github.com/repos/netbox-community/netbox/releases'` to check for release in the official NetBox repository. - ---- - ## Date and Time Formatting You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index be4b4d762..f41c6892a 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -179,6 +179,14 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. PREFER_IPV4 = False +# This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour. +RELEASE_CHECK_TIMEOUT = 24 * 3600 + +# This repository is used to check whether there is a new release of NetBox available. Set to None to disable the +# version check or use the URL below to check for release in the official NetBox repository. +RELEASE_CHECK_URL = None +# RELEASE_CHECK_URL = 'https://api.github.com/repos/netbox-community/netbox/releases' + # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' @@ -195,14 +203,6 @@ SESSION_FILE_PATH = None # Time zone (default: UTC) TIME_ZONE = 'UTC' -# This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour. -UPDATE_CACHE_TIMEOUT = 24 * 3600 - -# This repository is used to check whether there is a new release of NetBox available. Set to None to disable the -# version check or use the URL below to check for release in the official NetBox repository. -UPDATE_REPO_URL = None -# UPDATE_REPO_URL = 'https://api.github.com/repos/netbox-community/netbox/releases' - # Date/time formatting. See the following link for supported formats: # https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date DATE_FORMAT = 'N j, Y' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2f9643c78..7e4e8a1d9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -97,6 +97,8 @@ NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) +RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) +RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) @@ -105,22 +107,20 @@ SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') -UPDATE_REPO_URL = getattr(configuration, 'UPDATE_REPO_URL', None) -UPDATE_CACHE_TIMEOUT = getattr(configuration, 'UPDATE_CACHE_TIMEOUT', 24 * 3600) # Validate update repo URL and timeout -if UPDATE_REPO_URL: +if RELEASE_CHECK_URL: try: - URLValidator(UPDATE_REPO_URL) + URLValidator(RELEASE_CHECK_URL) except ValidationError: raise ImproperlyConfigured( - "UPDATE_REPO_URL must be a valid API URL. Example: " + "RELEASE_CHECK_URL must be a valid API URL. Example: " "https://api.github.com/repos/netbox-community/netbox" ) # Enforce a minimum cache timeout for update checks -if UPDATE_CACHE_TIMEOUT < 3600: - raise ImproperlyConfigured("UPDATE_CACHE_TIMEOUT has to be at least 3600 seconds (1 hour)") +if RELEASE_CHECK_TIMEOUT < 3600: + raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") # @@ -576,16 +576,7 @@ SWAGGER_SETTINGS = { # Django RQ (Webhooks backend) # -if not TASKS_REDIS_USING_SENTINEL: - RQ_PARAMS = { - 'HOST': TASKS_REDIS_HOST, - 'PORT': TASKS_REDIS_PORT, - 'DB': TASKS_REDIS_DATABASE, - 'PASSWORD': TASKS_REDIS_PASSWORD, - 'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT, - 'SSL': TASKS_REDIS_SSL, - } -else: +if TASKS_REDIS_USING_SENTINEL: RQ_PARAMS = { 'SENTINELS': TASKS_REDIS_SENTINELS, 'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE, @@ -596,6 +587,15 @@ else: 'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT }, } +else: + RQ_PARAMS = { + 'HOST': TASKS_REDIS_HOST, + 'PORT': TASKS_REDIS_PORT, + 'DB': TASKS_REDIS_DATABASE, + 'PASSWORD': TASKS_REDIS_PASSWORD, + 'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT, + 'SSL': TASKS_REDIS_SSL, + } RQ_QUEUES = { 'default': RQ_PARAMS, # Webhooks diff --git a/netbox/netbox/tests/test_get_releases.py b/netbox/netbox/tests/test_get_releases.py index 8d75e4dfc..635a6782b 100644 --- a/netbox/netbox/tests/test_get_releases.py +++ b/netbox/netbox/tests/test_get_releases.py @@ -56,7 +56,7 @@ def unsuccessful_github_response(url, *_args, **_kwargs): return r -@override_settings(UPDATE_REPO_URL='https://localhost/unittest/releases', UPDATE_CACHE_TIMEOUT=160876) +@override_settings(RELEASE_CHECK_URL='https://localhost/unittest/releases', RELEASE_CHECK_TIMEOUT=160876) class GetReleasesTestCase(SimpleTestCase): @patch.object(requests, 'get') @patch.object(RedisCache, 'set') diff --git a/netbox/utilities/background_tasks.py b/netbox/utilities/background_tasks.py index 68eebbcc8..1255846b7 100644 --- a/netbox/utilities/background_tasks.py +++ b/netbox/utilities/background_tasks.py @@ -12,7 +12,7 @@ logger = logging.getLogger('netbox.releases') @job('check_releases') def get_releases(pre_releases=False): - url = settings.UPDATE_REPO_URL + url = settings.RELEASE_CHECK_URL headers = { 'Accept': 'application/vnd.github.v3+json', } @@ -21,7 +21,7 @@ def get_releases(pre_releases=False): # Check whether this URL has failed recently and shouldn't be retried yet try: if url == cache.get('latest_release_no_retry'): - logger.debug("Skipping release check; URL failed recently: {}".format(url)) + logger.info("Skipping release check; URL failed recently: {}".format(url)) return [] except CacheMiss: pass @@ -47,6 +47,6 @@ def get_releases(pre_releases=False): return [] # Cache the most recent release - cache.set('latest_release', max(releases), settings.UPDATE_CACHE_TIMEOUT) + cache.set('latest_release', max(releases), settings.RELEASE_CHECK_TIMEOUT) return releases diff --git a/netbox/utilities/releases.py b/netbox/utilities/releases.py index 841cc8b0c..27279cc16 100644 --- a/netbox/utilities/releases.py +++ b/netbox/utilities/releases.py @@ -10,7 +10,7 @@ logger = logging.getLogger('netbox.releases') def get_latest_release(pre_releases=False): - if settings.UPDATE_REPO_URL: + if settings.RELEASE_CHECK_URL: logger.debug("Checking for most recent release") try: latest_release = cache.get('latest_release') @@ -21,13 +21,13 @@ def get_latest_release(pre_releases=False): # Check for an existing job. This can happen if the RQ worker process is not running. queue = get_queue('check_releases') if queue.jobs: - logger.debug("Job to check for new releases is already queued; skipping") + logger.warning("Job to check for new releases is already queued; skipping") else: # Get the releases in the background worker, it will fill the cache - logger.debug("Initiating background task to retrieve updated releases list") + logger.info("Initiating background task to retrieve updated releases list") get_releases.delay(pre_releases=pre_releases) else: - logger.debug("Skipping release check; UPDATE_REPO_URL not defined") + logger.debug("Skipping release check; RELEASE_CHECK_URL not defined") return 'unknown', None From 7246fd667e38b19be078b14420a26facb4d44294 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 13:54:41 -0400 Subject: [PATCH 48/66] Move releases.py to netbox/ --- netbox/{utilities => netbox}/releases.py | 0 netbox/netbox/tests/{test_get_releases.py => test_releases.py} | 0 netbox/netbox/views.py | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename netbox/{utilities => netbox}/releases.py (100%) rename netbox/netbox/tests/{test_get_releases.py => test_releases.py} (100%) diff --git a/netbox/utilities/releases.py b/netbox/netbox/releases.py similarity index 100% rename from netbox/utilities/releases.py rename to netbox/netbox/releases.py diff --git a/netbox/netbox/tests/test_get_releases.py b/netbox/netbox/tests/test_releases.py similarity index 100% rename from netbox/netbox/tests/test_get_releases.py rename to netbox/netbox/tests/test_releases.py diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 2741cf16d..bc87a825b 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -27,13 +27,13 @@ from extras.models import ObjectChange, ReportResult from ipam.filters import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable +from netbox.releases import get_latest_release from secrets.filters import SecretFilterSet from secrets.models import Secret from secrets.tables import SecretTable from tenancy.filters import TenantFilterSet from tenancy.models import Tenant from tenancy.tables import TenantTable -from utilities.releases import get_latest_release from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine from virtualization.tables import ClusterTable, VirtualMachineDetailTable From eab79faaebdc8ee1e5d94c80d1d918d0fa62ae4e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 14:02:24 -0400 Subject: [PATCH 49/66] Changelog for #738 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index b34731403..7395751bb 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -4,6 +4,7 @@ ### Enhancements +* [#738](https://github.com/netbox-community/netbox/issues/738) - Add ability to automatically check for new releases (must be enabled by setting `RELEASE_CHECK_URL`) * [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views * [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations From 87f0b19dc0147e5329c61f58a1c62d3816c5f5b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 14:43:19 -0400 Subject: [PATCH 50/66] Closes #4380: Enable webhooks for rack reservations --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/models/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 7395751bb..4a1df8146 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -7,6 +7,7 @@ * [#738](https://github.com/netbox-community/netbox/issues/738) - Add ability to automatically check for new releases (must be enabled by setting `RELEASE_CHECK_URL`) * [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views * [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations +* [#4380](https://github.com/netbox-community/netbox/issues/4380) - Enable webhooks for rack reservations ### Bug Fixes diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 63c3044c1..d525275fe 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -739,6 +739,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return 0 +@extras_features('webhooks') class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. From a9d04547d1a15b70866354358e05d75fbe5288dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 14:46:23 -0400 Subject: [PATCH 51/66] Closes #4381: Enable export templates for rack reservations --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/models/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 4a1df8146..4891071f2 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -8,6 +8,7 @@ * [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views * [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations * [#4380](https://github.com/netbox-community/netbox/issues/4380) - Enable webhooks for rack reservations +* [#4381](https://github.com/netbox-community/netbox/issues/4381) - Enable export templates for rack reservations ### Bug Fixes diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d525275fe..23b2618b5 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -739,7 +739,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return 0 -@extras_features('webhooks') +@extras_features('export_templates', 'webhooks') class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. From 7f5571200c5deeb0d805fe8bee80af210f70a399 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Mar 2020 14:50:49 -0400 Subject: [PATCH 52/66] Closes #4382: Enable custom links for rack reservations --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/models/__init__.py | 2 +- netbox/templates/dcim/rackreservation.html | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 4891071f2..654f1726d 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ * [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations * [#4380](https://github.com/netbox-community/netbox/issues/4380) - Enable webhooks for rack reservations * [#4381](https://github.com/netbox-community/netbox/issues/4381) - Enable export templates for rack reservations +* [#4382](https://github.com/netbox-community/netbox/issues/4382) - Enable custom links for rack reservations ### Bug Fixes diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 23b2618b5..5b93d3598 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -739,7 +739,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return 0 -@extras_features('export_templates', 'webhooks') +@extras_features('custom_links', 'export_templates', 'webhooks') class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index ef9e49d23..be9766557 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -36,6 +36,9 @@

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

{% include 'inc/created_updated.html' with obj=rackreservation %} +
+ {% custom_links rackreservation %} +
{% endif %} {{ termination.cable }} + + + {% if termination.connected_endpoint %} to {{ termination.connected_endpoint.device }} {{ termination.connected_endpoint }} From 59e63863610eefe27a0d9ca779b44168a011cce4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Mar 2020 12:40:21 -0400 Subject: [PATCH 66/66] Release v2.7.11 --- docs/release-notes/version-2.7.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 9bd63c186..047ee3549 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,6 +1,6 @@ # NetBox v2.7 Release Notes -## v2.7.11 (FUTURE) +## v2.7.11 (2020-03-27) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7e4e8a1d9..0937279b2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -15,7 +15,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.7.11-dev' +VERSION = '2.7.11' # Hostname HOSTNAME = platform.node()