diff --git a/.gitattributes b/.gitattributes index dfdb8b771..9ad1ee25e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ *.sh text eol=lf +# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable +*.min.* binary +*.map binary +*.pack.js binary diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index d924d2c0b..8fe86af80 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -41,7 +41,14 @@ Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for ### Manually Perform a New Install -Create a new installation of NetBox by following [the current documentation](http://netbox.readthedocs.io/en/latest/). This should be a manual process, so that issues with the documentation can be identified and corrected. +Install `mkdocs` in your local environment, then start the documentation server: + +```no-highlight +$ pip install -r docs/requirements.txt +$ mkdocs serve +``` + +Follow these instructions to perform a new installation of NetBox. This process must _not_ be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release. ### Close the Release Milestone diff --git a/docs/models/extras/imageattachment.md b/docs/models/extras/imageattachment.md new file mode 100644 index 000000000..da15462ab --- /dev/null +++ b/docs/models/extras/imageattachment.md @@ -0,0 +1,3 @@ +# Image Attachments + +Certain objects in NetBox support the attachment of uploaded images. These will be saved to the NetBox server and made available whenever the object is viewed. diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 3b18faa81..35f91a94d 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,12 +1,35 @@ # NetBox v2.8 -## v2.8.8 (FUTURE) +## v2.8.9 (FUTURE) ### Bug Fixes +* [#4875](https://github.com/netbox-community/netbox/issues/4875) - Fix documentation for image attachments +* [#4876](https://github.com/netbox-community/netbox/issues/4876) - Fix labels for sites in staging or decommissioning status +* [#4880](https://github.com/netbox-community/netbox/issues/4880) - Fix remove tagged vlans if not assigned in bulk interface editting + +--- + +## v2.8.8 (2020-07-21) + +### Enhancements + +* [#4805](https://github.com/netbox-community/netbox/issues/4805) - Improve handling of plugin loading errors +* [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types +* [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set +* [#4854](https://github.com/netbox-community/netbox/issues/4854) - Add staging and decommissioning statuses for sites + +### Bug Fixes + +* [#3240](https://github.com/netbox-community/netbox/issues/3240) - Correct OpenAPI definition for available-prefixes endpoint +* [#4595](https://github.com/netbox-community/netbox/issues/4595) - Ensure consistent display of non-racked and child devices on rack view +* [#4803](https://github.com/netbox-community/netbox/issues/4803) - Return IP family (4 or 6) as integer rather than string * [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs * [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields * [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices +* [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations +* [#4856](https://github.com/netbox-community/netbox/issues/4856) - Redirect user back to circuit after connecting a termination +* [#4872](https://github.com/netbox-community/netbox/issues/4872) - Enable filtering virtual machine interfaces by tag --- diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 59f30a206..4e2ef1388 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -7,13 +7,17 @@ from utilities.choices import ChoiceSet class SiteStatusChoices(ChoiceSet): - STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' CHOICES = ( - (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), + (STATUS_STAGING, 'Staging'), + (STATUS_ACTIVE, 'Active'), + (STATUS_DECOMMISSIONING, 'Decommissioning'), (STATUS_RETIRED, 'Retired'), ) @@ -275,6 +279,11 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_1430P = 'nema-14-30p' TYPE_NEMA_1450P = 'nema-14-50p' TYPE_NEMA_1460P = 'nema-14-60p' + TYPE_NEMA_1515P = 'nema-15-15p' + TYPE_NEMA_1520P = 'nema-15-20p' + TYPE_NEMA_1530P = 'nema-15-30p' + TYPE_NEMA_1550P = 'nema-15-50p' + TYPE_NEMA_1560P = 'nema-15-60p' # NEMA locking TYPE_NEMA_L115P = 'nema-l1-15p' TYPE_NEMA_L515P = 'nema-l5-15p' @@ -290,6 +299,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_L1430P = 'nema-l14-30p' TYPE_NEMA_L1450P = 'nema-l14-50p' TYPE_NEMA_L1460P = 'nema-l14-60p' + TYPE_NEMA_L1520P = 'nema-l15-20p' + TYPE_NEMA_L1530P = 'nema-l15-30p' + TYPE_NEMA_L1550P = 'nema-l15-50p' + TYPE_NEMA_L1560P = 'nema-l15-60p' TYPE_NEMA_L2120P = 'nema-l21-20p' TYPE_NEMA_L2130P = 'nema-l21-30p' # California style @@ -351,6 +364,11 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_1430P, 'NEMA 14-30P'), (TYPE_NEMA_1450P, 'NEMA 14-50P'), (TYPE_NEMA_1460P, 'NEMA 14-60P'), + (TYPE_NEMA_1515P, 'NEMA 15-15P'), + (TYPE_NEMA_1520P, 'NEMA 15-20P'), + (TYPE_NEMA_1530P, 'NEMA 15-30P'), + (TYPE_NEMA_1550P, 'NEMA 15-50P'), + (TYPE_NEMA_1560P, 'NEMA 15-60P'), )), ('NEMA (Locking)', ( (TYPE_NEMA_L115P, 'NEMA L1-15P'), @@ -367,6 +385,10 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_L1430P, 'NEMA L14-30P'), (TYPE_NEMA_L1450P, 'NEMA L14-50P'), (TYPE_NEMA_L1460P, 'NEMA L14-60P'), + (TYPE_NEMA_L1520P, 'NEMA L15-20P'), + (TYPE_NEMA_L1530P, 'NEMA L15-30P'), + (TYPE_NEMA_L1550P, 'NEMA L15-50P'), + (TYPE_NEMA_L1560P, 'NEMA L15-60P'), (TYPE_NEMA_L2120P, 'NEMA L21-20P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'), )), @@ -436,6 +458,11 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_1430R = 'nema-14-30r' TYPE_NEMA_1450R = 'nema-14-50r' TYPE_NEMA_1460R = 'nema-14-60r' + TYPE_NEMA_1515R = 'nema-15-15r' + TYPE_NEMA_1520R = 'nema-15-20r' + TYPE_NEMA_1530R = 'nema-15-30r' + TYPE_NEMA_1550R = 'nema-15-50r' + TYPE_NEMA_1560R = 'nema-15-60r' # NEMA locking TYPE_NEMA_L115R = 'nema-l1-15r' TYPE_NEMA_L515R = 'nema-l5-15r' @@ -451,6 +478,10 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_L1430R = 'nema-l14-30r' TYPE_NEMA_L1450R = 'nema-l14-50r' TYPE_NEMA_L1460R = 'nema-l14-60r' + TYPE_NEMA_L1520R = 'nema-l15-20r' + TYPE_NEMA_L1530R = 'nema-l15-30r' + TYPE_NEMA_L1550R = 'nema-l15-50r' + TYPE_NEMA_L1560R = 'nema-l15-60r' TYPE_NEMA_L2120R = 'nema-l21-20r' TYPE_NEMA_L2130R = 'nema-l21-30r' # California style @@ -513,6 +544,11 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_1430R, 'NEMA 14-30R'), (TYPE_NEMA_1450R, 'NEMA 14-50R'), (TYPE_NEMA_1460R, 'NEMA 14-60R'), + (TYPE_NEMA_1515R, 'NEMA 15-15R'), + (TYPE_NEMA_1520R, 'NEMA 15-20R'), + (TYPE_NEMA_1530R, 'NEMA 15-30R'), + (TYPE_NEMA_1550R, 'NEMA 15-50R'), + (TYPE_NEMA_1560R, 'NEMA 15-60R'), )), ('NEMA (Locking)', ( (TYPE_NEMA_L115R, 'NEMA L1-15R'), @@ -529,6 +565,10 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_L1430R, 'NEMA L14-30R'), (TYPE_NEMA_L1450R, 'NEMA L14-50R'), (TYPE_NEMA_L1460R, 'NEMA L14-60R'), + (TYPE_NEMA_L1520R, 'NEMA L15-20R'), + (TYPE_NEMA_L1530R, 'NEMA L15-30R'), + (TYPE_NEMA_L1550R, 'NEMA L15-50R'), + (TYPE_NEMA_L1560R, 'NEMA L15-60R'), (TYPE_NEMA_L2120R, 'NEMA L21-20R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'), )), diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 993de734c..ef5b07aca 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -254,8 +254,10 @@ class Site(ChangeLoggedModel, CustomFieldModel): ] STATUS_CLASS_MAP = { - SiteStatusChoices.STATUS_ACTIVE: 'success', SiteStatusChoices.STATUS_PLANNED: 'info', + SiteStatusChoices.STATUS_STAGING: 'primary', + SiteStatusChoices.STATUS_ACTIVE: 'success', + SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning', SiteStatusChoices.STATUS_RETIRED: 'danger', } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 748e41139..bb5182a83 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -399,11 +399,12 @@ class RackView(PermissionRequiredMixin, View): rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) + # Get 0U and child devices located within the rack nonracked_devices = Device.objects.filter( rack=rack, - position__isnull=True, - parent_bay__isnull=True + position__isnull=True ).prefetch_related('device_type__manufacturer') + if rack.group: peer_racks = Rack.objects.filter(site=rack.site, group=rack.group) else: diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index ee7f59196..159d1a023 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -6,11 +6,12 @@ from django.apps import AppConfig from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.template.loader import get_template -from django.utils.module_loading import import_string from extras.registry import registry from utilities.choices import ButtonColorChoices +from extras.plugins.utils import import_object + # Initialize plugin registry stores registry['plugin_template_extensions'] = collections.defaultdict(list) @@ -60,18 +61,14 @@ class PluginConfig(AppConfig): def ready(self): # Register template content - try: - template_extensions = import_string(f"{self.__module__}.{self.template_extensions}") + template_extensions = import_object(f"{self.__module__}.{self.template_extensions}") + if template_extensions is not None: register_template_extensions(template_extensions) - except ImportError: - pass # Register navigation menu items (if defined) - try: - menu_items = import_string(f"{self.__module__}.{self.menu_items}") + menu_items = import_object(f"{self.__module__}.{self.menu_items}") + if menu_items is not None: register_menu_items(self.verbose_name, menu_items) - except ImportError: - pass @classmethod def validate(cls, user_config): diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py index b4360dc9e..7ab293916 100644 --- a/netbox/extras/plugins/urls.py +++ b/netbox/extras/plugins/urls.py @@ -3,7 +3,8 @@ from django.conf import settings from django.conf.urls import include from django.contrib.admin.views.decorators import staff_member_required from django.urls import path -from django.utils.module_loading import import_string + +from extras.plugins.utils import import_object from . import views @@ -24,19 +25,15 @@ for plugin_path in settings.PLUGINS: base_url = getattr(app, 'base_url') or app.label # Check if the plugin specifies any base URLs - try: - urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns") + urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns") + if urlpatterns is not None: plugin_patterns.append( path(f"{base_url}/", include((urlpatterns, app.label))) ) - except ImportError: - pass # Check if the plugin specifies any API URLs - try: - urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns") + urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns") + if urlpatterns is not None: plugin_api_patterns.append( path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) ) - except ImportError: - pass diff --git a/netbox/extras/plugins/utils.py b/netbox/extras/plugins/utils.py new file mode 100644 index 000000000..87240aba8 --- /dev/null +++ b/netbox/extras/plugins/utils.py @@ -0,0 +1,33 @@ +import importlib.util +import sys + + +def import_object(module_and_object): + """ + Import a specific object from a specific module by name, such as "extras.plugins.utils.import_object". + + Returns the imported object, or None if it doesn't exist. + """ + target_module_name, object_name = module_and_object.rsplit('.', 1) + module_hierarchy = target_module_name.split('.') + + # Iterate through the module hierarchy, checking for the existence of each successive submodule. + # We have to do this rather than jumping directly to calling find_spec(target_module_name) + # because find_spec will raise a ModuleNotFoundError if any parent module of target_module_name does not exist. + module_name = "" + for module_component in module_hierarchy: + module_name = f"{module_name}.{module_component}" if module_name else module_component + spec = importlib.util.find_spec(module_name) + if spec is None: + # No such module + return None + + # Okay, target_module_name exists. Load it if not already loaded + if target_module_name in sys.modules: + module = sys.modules[target_module_name] + else: + module = importlib.util.module_from_spec(spec) + sys.modules[target_module_name] = module + spec.loader.exec_module(module) + + return getattr(module, object_name, None) diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py index 39aa692d7..f30e0f539 100644 --- a/netbox/extras/plugins/views.py +++ b/netbox/extras/plugins/views.py @@ -4,13 +4,14 @@ from django.apps import apps from django.conf import settings from django.shortcuts import render from django.urls.exceptions import NoReverseMatch -from django.utils.module_loading import import_string from django.views.generic import View from rest_framework import permissions from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView +from extras.plugins.utils import import_object + class InstalledPluginsAdminView(View): """ @@ -60,9 +61,9 @@ class PluginsAPIRootView(APIView): @staticmethod def _get_plugin_entry(plugin, app_config, request, format): - try: - api_app_name = import_string(f"{plugin}.api.urls.app_name") - except (ImportError, ModuleNotFoundError): + # Check if the plugin specifies any API URLs + api_app_name = import_object(f"{plugin}.api.urls.app_name") + if api_app_name is None: # Plugin does not expose an API return None @@ -73,7 +74,7 @@ class PluginsAPIRootView(APIView): format=format )) except NoReverseMatch: - # The plugin does not include an api-root + # The plugin does not include an api-root url entry = None return entry diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index abe75b261..b4313ac03 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -44,6 +44,7 @@ class NestedRIRSerializer(WritableNestedSerializer): class NestedAggregateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') + family = serializers.IntegerField(read_only=True) class Meta: model = models.Aggregate @@ -87,6 +88,7 @@ class NestedVLANSerializer(WritableNestedSerializer): class NestedPrefixSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') + family = serializers.IntegerField(read_only=True) class Meta: model = models.Prefix @@ -99,6 +101,7 @@ class NestedPrefixSerializer(WritableNestedSerializer): class NestedIPAddressSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') + family = serializers.IntegerField(read_only=True) class Meta: model = models.IPAddress diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index c741ad0f4..6aab3a6d8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -74,6 +74,11 @@ class PrefixViewSet(CustomFieldModelViewSet): serializer_class = serializers.PrefixSerializer filterset_class = filters.PrefixFilterSet + def get_serializer_class(self): + if self.action == "available_prefixes" and self.request.method == "POST": + return serializers.PrefixLengthSerializer + return super().get_serializer_class() + @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)}) @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)}) @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index dfa0ddfff..266456e55 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.8-dev' +VERSION = '2.8.9-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 8db715711..901ba2a2b 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -51,10 +51,15 @@ - {% if termination.connected_endpoint %} - to {{ termination.connected_endpoint.device }} - {{ termination.connected_endpoint }} - {% endif %} + {% with peer=termination.get_cable_peer %} + to + {% if peer.device %} + {{ peer.device }} + {% elif peer.circuit %} + {{ peer.circuit }} + {% endif %} + ({{ peer }}) + {% endwith %} {% else %} {% if perms.dcim.add_cable %}
@@ -63,10 +68,10 @@ Connect
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 20e31151d..124aeeeda 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -337,7 +337,7 @@ Name Role Type - Parent + Parent Device {% for device in nonracked_devices %} @@ -346,13 +346,12 @@ {{ device.device_role }} {{ device.device_type.display_name }} - - {% if device.parent_bay %} - {{ device.parent_bay }} - {% else %} - - {% endif %} - + {% if device.parent_bay %} + {{ device.parent_bay.device }} + {{ device.parent_bay }} + {% else %} + — + {% endif %} {% endfor %} diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 85f75f79e..77320980d 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -27,12 +27,12 @@ def _get_viewname(instance, action): @register.inclusion_tag('buttons/clone.html') def clone_button(instance): - viewname = _get_viewname(instance, 'add') + url = reverse(_get_viewname(instance, 'add')) # Populate cloned field values param_string = prepare_cloned_fields(instance) if param_string: - url = '{}?{}'.format(reverse(viewname), param_string) + url = f'{url}?{param_string}' return { 'url': url, diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 38fb6d963..cd8ac03c9 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -721,8 +721,8 @@ class BulkEditView(GetReturnURLMixin, View): # ManyToManyFields elif isinstance(model_field, ManyToManyField): - getattr(obj, name).set(form.cleaned_data[name]) - + if form.cleaned_data[name].count() > 0: + getattr(obj, name).set(form.cleaned_data[name]) # Normal fields elif form.cleaned_data[name] not in (None, ''): setattr(obj, name, form.cleaned_data[name]) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index a54b6ab28..5c9b64aa3 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet): mac_address = MultiValueMACAddressFilter( label='MAC address', ) + tag = TagFilter() class Meta: model = Interface