diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 59746cacda8..00000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,49 +0,0 @@ - -### Issue type -[ ] Feature request -[ ] Bug report -[ ] Documentation -[ ] Housekeeping - - -### Environment -* Python version: -* NetBox version: - - -### Description diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000000..20868696e91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: :bug: Bug Report +about: Report a reproducible bug in the current release of NetBox +--- + + +### Environment +* Python version: +* NetBox version: + + +### Steps to Reproduce + + + +### Expected Behavior + + + +### Observed Behavior diff --git a/.github/ISSUE_TEMPLATE/documentation_change.md b/.github/ISSUE_TEMPLATE/documentation_change.md new file mode 100644 index 00000000000..a211116ccb8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_change.md @@ -0,0 +1,17 @@ +--- +name: :book: Documentation Change +about: Suggest an addition or modification to the NetBox documentation +--- + + +### Change Type +[ ] Addition +[ ] Correction +[ ] Deprecation +[ ] Cleanup (formatting, typos, etc.) + + +### Proposed Changes diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..b9dc931b6c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,53 @@ +--- +name: :new: Feature Request +about: Propose a new NetBox feature or enhancement +--- + + +### Environment +* Python version: +* NetBox version: + + +### Proposed Functionality + + + +### Use Case + + + +### Database Changes + + + +### External Dependencies diff --git a/.github/ISSUE_TEMPLATE/housekeeping.md b/.github/ISSUE_TEMPLATE/housekeeping.md new file mode 100644 index 00000000000..0ca400cff49 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/housekeeping.md @@ -0,0 +1,16 @@ +--- +name: :house: Housekeeping +about: A change pertaining to the codebase itself +--- + + +### Proposed Changes + + + +### Justification --> diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4e9185a89c2..7f3d0935ac4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,8 @@ be able to accept. Please indicate the relevant feature request or bug report below. + IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR + FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED. --> ### Fixes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4820e5a8545..546e1de09aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,11 +91,13 @@ appropriate labels will be applied for categorization. ## Submitting Pull Requests -* Be sure to open an issue before starting work on a pull request, and discuss -your idea with the NetBox maintainers before beginning work​. This will help -prevent wasting time on something that might we might not be able to implement. -When suggesting a new feature, also make sure it won't conflict with any work -that's already in progress. +* Be sure to open an issue **before** starting work on a pull request, and +discuss your idea with the NetBox maintainers before beginning work. This will +help prevent wasting time on something that might we might not be able to +implement. When suggesting a new feature, also make sure it won't conflict with +any work that's already in progress. + +* Any pull request which does _not_ relate to an accepted issue will be closed. * When submitting a pull request, please be sure to work off of the `develop` branch, rather than `master`. The `develop` branch is used for ongoing diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ce8b4c34953..befde771f9b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -267,7 +267,7 @@ def napalm(self, request, pk): import napalm except ImportError: raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") - from napalm.base.exceptions import ConnectAuthError, ModuleImportError + from napalm.base.exceptions import ModuleImportError # Validate the configured driver try: @@ -281,16 +281,8 @@ def napalm(self, request, pk): if not request.user.has_perm('dcim.napalm_read'): return HttpResponseForbidden() - # Validate requested NAPALM methods + # Connect to the device napalm_methods = request.GET.getlist('method') - for method in napalm_methods: - if not hasattr(driver, method): - return HttpResponseBadRequest("Unknown NAPALM method: {}".format(method)) - elif not method.startswith('get_'): - return HttpResponseBadRequest("Unsupported NAPALM method: {}".format(method)) - - # Connect to the device and execute the requested methods - # TODO: Improve error handling response = OrderedDict([(m, None) for m in napalm_methods]) ip_address = str(device.primary_ip.address.ip) d = driver( @@ -302,12 +294,23 @@ def napalm(self, request, pk): ) try: d.open() - for method in napalm_methods: - response[method] = getattr(d, method)() except Exception as e: raise ServiceUnavailable("Error connecting to the device at {}: {}".format(ip_address, e)) + # Validate and execute each specified NAPALM method + for method in napalm_methods: + if not hasattr(driver, method): + response[method] = {'error': 'Unknown NAPALM method'} + continue + if not method.startswith('get_'): + response[method] = {'error': 'Only get_* NAPALM methods are supported'} + continue + try: + response[method] = getattr(d, method)() + except NotImplementedError: + response[method] = {'error': 'Method not implemented for NAPALM driver {}'.format(driver)} d.close() + return Response(response) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 0d5455aa09f..701ea111f1d 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -509,7 +509,7 @@ def search(self, queryset, name, value): Q(name__icontains=value) | Q(serial__icontains=value.strip()) | Q(inventory_items__serial__icontains=value.strip()) | - Q(asset_tag=value.strip()) | + Q(asset_tag__icontains=value.strip()) | Q(comments__icontains=value) ).distinct() diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 14c9ef39389..107dcba51cf 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -781,9 +781,6 @@ class Meta: def __str__(self): return self.name - def get_absolute_url(self): - return "{}?role={}".format(reverse('dcim:device_list'), self.slug) - def to_csv(self): return ( self.name, diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index c29a8a8572b..80e47391a05 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -11,13 +11,8 @@ def assign_virtualchassis_master(instance, created, **kwargs): """ When a VirtualChassis is created, automatically assign its master device to the VC. """ - # Default to 1 but don't overwrite an existing position (see #2087) - if instance.master.vc_position is not None: - vc_position = instance.master.vc_position - else: - vc_position = 1 if created: - Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=vc_position) + Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=None) @receiver(pre_delete, sender=VirtualChassis) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 427687ef965..eb4f74157c7 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -408,7 +408,6 @@ class Meta(BaseTable.Meta): class DeviceRoleTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') device_count = tables.TemplateColumn( template_code=DEVICEROLE_DEVICE_COUNT, accessor=Accessor('devices.count'), diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 31e899afd12..c709062b09f 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -196,8 +196,9 @@ def available_ips(self, request, pk=None): # Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix available_ips = iter(available_ips) + prefix_length = prefix.prefix.prefixlen for requested_ip in requested_ips: - requested_ip['address'] = next(available_ips) + requested_ip['address'] = '{}/{}'.format(next(available_ips), prefix_length) requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None # Initialize the serializer with a list or a single object depending on what was requested diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 08035d54917..382eeae209f 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -194,17 +194,35 @@ class Meta(BaseTable.Meta): class RIRDetailTable(RIRTable): - stats_total = tables.Column(accessor='stats.total', verbose_name='Total', - footer=lambda table: sum(r.stats['total'] for r in table.data)) - stats_active = tables.Column(accessor='stats.active', verbose_name='Active', - footer=lambda table: sum(r.stats['active'] for r in table.data)) - stats_reserved = tables.Column(accessor='stats.reserved', verbose_name='Reserved', - footer=lambda table: sum(r.stats['reserved'] for r in table.data)) - stats_deprecated = tables.Column(accessor='stats.deprecated', verbose_name='Deprecated', - footer=lambda table: sum(r.stats['deprecated'] for r in table.data)) - stats_available = tables.Column(accessor='stats.available', verbose_name='Available', - footer=lambda table: sum(r.stats['available'] for r in table.data)) - utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization') + stats_total = tables.Column( + accessor='stats.total', + verbose_name='Total', + footer=lambda table: sum(r.stats['total'] for r in table.data) + ) + stats_active = tables.Column( + accessor='stats.active', + verbose_name='Active', + footer=lambda table: sum(r.stats['active'] for r in table.data) + ) + stats_reserved = tables.Column( + accessor='stats.reserved', + verbose_name='Reserved', + footer=lambda table: sum(r.stats['reserved'] for r in table.data) + ) + stats_deprecated = tables.Column( + accessor='stats.deprecated', + verbose_name='Deprecated', + footer=lambda table: sum(r.stats['deprecated'] for r in table.data) + ) + stats_available = tables.Column( + accessor='stats.available', + verbose_name='Available', + footer=lambda table: sum(r.stats['available'] for r in table.data) + ) + utilization = tables.TemplateColumn( + template_code=RIR_UTILIZATION, + verbose_name='Utilization' + ) class Meta(RIRTable.Meta): fields = ( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1d4575e3495..752ab97c248 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -192,9 +192,15 @@ def alter_queryset(self, request): queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix)) # Find all consumed space for each prefix status (we ignore containers for this purpose). - active_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)]) - reserved_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)]) - deprecated_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)]) + active_prefixes = netaddr.cidr_merge( + [p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)] + ) + reserved_prefixes = netaddr.cidr_merge( + [p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)] + ) + deprecated_prefixes = netaddr.cidr_merge( + [p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)] + ) # Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix. available_prefixes = ( @@ -205,11 +211,11 @@ def alter_queryset(self, request): ) # Add the size of each metric to the RIR total. - stats['total'] += aggregate.prefix.size / denominator - stats['active'] += netaddr.IPSet(active_prefixes).size / denominator - stats['reserved'] += netaddr.IPSet(reserved_prefixes).size / denominator - stats['deprecated'] += netaddr.IPSet(deprecated_prefixes).size / denominator - stats['available'] += available_prefixes.size / denominator + stats['total'] += int(aggregate.prefix.size / denominator) + stats['active'] += int(netaddr.IPSet(active_prefixes).size / denominator) + stats['reserved'] += int(netaddr.IPSet(reserved_prefixes).size / denominator) + stats['deprecated'] += int(netaddr.IPSet(deprecated_prefixes).size / denominator) + stats['available'] += int(available_prefixes.size / denominator) # Calculate the percentage of total space for each prefix status. total = float(stats['total']) @@ -229,20 +235,6 @@ def alter_queryset(self, request): return rirs - def extra_context(self): - - totals = { - 'total': sum([rir.stats['total'] for rir in self.queryset]), - 'active': sum([rir.stats['active'] for rir in self.queryset]), - 'reserved': sum([rir.stats['reserved'] for rir in self.queryset]), - 'deprecated': sum([rir.stats['deprecated'] for rir in self.queryset]), - 'available': sum([rir.stats['available'] for rir in self.queryset]), - } - - return { - 'totals': totals, - } - class RIRCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_rir' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5868af29103..8e0d5c8c29a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ DeprecationWarning ) -VERSION = '2.3.6' +VERSION = '2.3.7' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 4907555edc6..3a2a7205f36 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -74,3 +74,5 @@ urlpatterns = [ url(r'^{}'.format(settings.BASE_PATH), include(_patterns)) ] + +handler500 = 'utilities.views.server_error' diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index bd157082719..32bb8aea5b8 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -372,12 +372,19 @@ table.reports td.method { font-family: monospace; padding-left: 30px; } -table.reports td.stats label { +td.report-stats label { display: inline-block; line-height: 14px; margin-bottom: 0; min-width: 40px; } +table.report th { + position: relative; +} +table.report th a { + position: absolute; + top: -51px; +} /* AJAX loader */ .loading { diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 8e7fa3b1654..7a7e8161085 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -153,7 +153,8 @@ class Meta: model = UserKey fields = ['public_key'] help_texts = { - 'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption.", + 'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption. " + "Please note that passphrase-protected keys are not supported.", } def clean_public_key(self): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 7e36452cbe5..cf272449f9e 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -146,7 +146,7 @@ Role - {{ device.device_role }} + {{ device.device_role }} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 050d6e4456a..92753e23b07 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -29,63 +29,73 @@

{{ report.name }}{% include 'extras/inc/report_label.html' with result=repor

{{ report.description }}

{% endif %} {% if report.result %} -

Last run: {{ report.result.created }}

- {% else %} -

Last run: Never

+

Last run: {{ report.result.created }}

{% endif %} - -
{% if report.result %} - - - - - - - - - - {% for method, data in report.result.data.items %} - - - - {% for time, level, obj, url, message in data.log %} - - - -
TimeLevelObjectMessage
{{ method }}
{{ time }} - - - {% if obj and url %} - {{ obj }} - {% elif obj %} - {{ obj }} - {% endif %} +
+
+ Report Methods +
+ + {% for method, data in report.result.data.items %} + + + - {% endfor %} - {% endfor %} -
{{ method }} + + + + {{ message }}
+
+
+
+
+ Report Results +
+ + + + + + + + + + + {% for method, data in report.result.data.items %} + + + + {% for time, level, obj, url, message in data.log %} + + + + + + + {% endfor %} + {% endfor %} + +
TimeLevelObjectMessage
+ {{ method }} +
{{ time }} + + + {% if obj and url %} + {{ obj }} + {% elif obj %} + {{ obj }} + {% endif %} + {{ message }}
+
{% else %}
No results are available for this report. Please run the report first.
{% endif %}
{% if report.result %} -
-
- Methods -
-
    - {% for method, data in report.result.data.items %} -
  • - {{ method }} - {{ data.log|length }} -
  • - {% endfor %} -
-
{% endif %}
diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 1d6fbb72e31..7d2f8a2e2cf 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -38,7 +38,7 @@

{{ module|bettertitle }}

{{ method }} - + @@ -69,7 +69,7 @@

{{ module|bettertitle }}

{{ report.name }}
- {% include 'extras/inc/report_label.html' %} + {% include 'extras/inc/report_label.html' with result=report.result %}
{% endfor %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 1509f35cb2f..24889972b8a 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -65,10 +65,11 @@

{% block title %}{{ ipaddress }}{% endblock %}

Tenant {% if ipaddress.tenant %} + {% if ipaddress.tenant.group %} + {{ ipaddress.tenant.group }} + + {% endif %} {{ ipaddress.tenant }} - {% elif ipaddress.vrf.tenant %} - {{ ipaddress.vrf.tenant }} - {% else %} None {% endif %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 11c5fc405b9..1b23284f4cd 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -35,13 +35,6 @@ {% endif %} {{ prefix.tenant }} - {% elif prefix.vrf.tenant %} - {% if prefix.vrf.tenant.group %} - {{ prefix.vrf.tenant.group }} - - {% endif %} - {{ prefix.vrf.tenant }} - {% else %} None {% endif %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 99c4acc8a08..9020a8c19e9 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -78,14 +78,8 @@ def get(self, request, slug): 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(), 'device_count': Device.objects.filter(tenant=tenant).count(), 'vrf_count': VRF.objects.filter(tenant=tenant).count(), - 'prefix_count': Prefix.objects.filter( - Q(tenant=tenant) | - Q(tenant__isnull=True, vrf__tenant=tenant) - ).count(), - 'ipaddress_count': IPAddress.objects.filter( - Q(tenant=tenant) | - Q(tenant__isnull=True, vrf__tenant=tenant) - ).count(), + 'prefix_count': Prefix.objects.filter(tenant=tenant).count(), + 'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(), 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index 64fb70a07fc..70d018023fa 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -5,9 +5,10 @@ from django.conf import settings from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect -from django.shortcuts import render from django.urls import reverse +from .views import server_error + BASE_PATH = getattr(settings, 'BASE_PATH', False) LOGIN_REQUIRED = getattr(settings, 'LOGIN_REQUIRED', False) @@ -65,23 +66,19 @@ def process_exception(self, request, exception): if isinstance(exception, Http404): return - # Determine the type of exception + # Determine the type of exception. If it's a common issue, return a custom error page with instructions. + custom_template = None if isinstance(exception, ProgrammingError): - template_name = 'exceptions/programming_error.html' + custom_template = 'exceptions/programming_error.html' elif isinstance(exception, ImportError): - template_name = 'exceptions/import_error.html' + custom_template = 'exceptions/import_error.html' elif ( sys.version_info[0] >= 3 and isinstance(exception, PermissionError) ) or ( isinstance(exception, OSError) and exception.errno == 13 ): - template_name = 'exceptions/permission_error.html' - else: - template_name = '500.html' - - # Return an error message - type_, error, traceback = sys.exc_info() - return render(request, template_name, { - 'exception': str(type_), - 'error': error, - }, status=500) + custom_template = 'exceptions/permission_error.html' + + # Return a custom error message, or fall back to Django's default 500 error handling + if custom_template: + return server_error(request, template_name=custom_template) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 94b44fc481b..dcb4529b1e5 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -2,6 +2,7 @@ from collections import OrderedDict from copy import deepcopy +import sys from django.conf import settings from django.contrib import messages @@ -10,12 +11,16 @@ from django.db import transaction, IntegrityError from django.db.models import ProtectedError from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea +from django.http import HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render -from django.template.exceptions import TemplateSyntaxError +from django.template import loader +from django.template.exceptions import TemplateDoesNotExist, TemplateSyntaxError from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe +from django.views.decorators.csrf import requires_csrf_token +from django.views.defaults import ERROR_500_TEMPLATE_NAME from django.views.generic import View from django_tables2 import RequestConfig @@ -858,3 +863,20 @@ def post(self, request): 'table': table, 'return_url': reverse(self.default_return_url), }) + + +@requires_csrf_token +def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): + """ + Custom 500 handler to provide additional context when rendering 500.html. + """ + try: + template = loader.get_template(template_name) + except TemplateDoesNotExist: + return HttpResponseServerError('

Server Error (500)

', content_type='text/html') + type_, error, traceback = sys.exc_info() + + return HttpResponseServerError(template.render({ + 'exception': str(type_), + 'error': error, + }))