diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index f63a8f170..26ece9049 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,7 +17,7 @@ body: What version of NetBox are you currently running? (If you don't have access to the most recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) before opening a bug report to see if your issue has already been addressed.) - placeholder: v3.0.4 + placeholder: v3.0.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index d39c2210e..ecbef8b09 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.4 + placeholder: v3.0.5 validations: required: true - type: dropdown diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 252e65f90..cf052f918 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -45,6 +45,20 @@ Defining script variables is optional: You may create a script with only a `run( Any output generated by the script during its execution will be displayed under the "output" tab in the UI. +By default, scripts within a module are ordered alphabetically in the scripts list page. To return scripts in a specific order, you can define the `script_order` variable at the end of your module. The `script_order` variable is a tuple which contains each Script class in the desired order. Any scripts that are omitted from this list will be listed last. + +```python +from extras.scripts import Script + +class MyCustomScript(Script): + ... + +class AnotherCustomScript(Script): + ... + +script_order = (MyCustomScript, AnotherCustomScript) +``` + ## Module Attributes ### `name` diff --git a/docs/installation/index.md b/docs/installation/index.md index 375338d7e..12d78c439 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -11,7 +11,7 @@ The following sections detail how to set up a new instance of NetBox: 5. [HTTP server](5-http-server.md) 6. [LDAP authentication](6-ldap.md) (optional) -The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference. +The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference. diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index d25c4c441..978c860ea 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,6 +1,28 @@ # NetBox v3.0 -## v3.0.5 (FUTURE) +## v3.0.5 (2021-10-04) + +### Enhancements + +* [#5925](https://github.com/netbox-community/netbox/issues/5925) - Always show IP addresses tab under prefix view +* [#6423](https://github.com/netbox-community/netbox/issues/6423) - Cache rendered REST API specifications +* [#6708](https://github.com/netbox-community/netbox/issues/6708) - Add image attachment support for circuits, power panels +* [#7387](https://github.com/netbox-community/netbox/issues/7387) - Enable arbitrary ordering of custom scripts +* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table + +### Bug Fixes + +* [#6433](https://github.com/netbox-community/netbox/issues/6433) - Fix bulk editing of child prefixes under aggregate view +* [#6817](https://github.com/netbox-community/netbox/issues/6817) - Custom field columns should be removed from tables upon their deletion +* [#6895](https://github.com/netbox-community/netbox/issues/6895) - Remove errant markup for null values in CSV export +* [#7215](https://github.com/netbox-community/netbox/issues/7215) - Prevent rack elevations from overlapping when higher width is specified +* [#7373](https://github.com/netbox-community/netbox/issues/7373) - Fix flashing when server, client, and browser color-mode preferences are mismatched +* [#7397](https://github.com/netbox-community/netbox/issues/7397) - Fix AttributeError exception when rendering export template for devices via REST API +* [#7401](https://github.com/netbox-community/netbox/issues/7401) - Pin `jsonschema` package to v3.2.0 to fix REST API docs rendering +* [#7411](https://github.com/netbox-community/netbox/issues/7411) - Fix exception in UI when adding member devices to virtual chassis +* [#7412](https://github.com/netbox-community/netbox/issues/7412) - Fix exception in UI when adding child device to device bay +* [#7417](https://github.com/netbox-community/netbox/issues/7417) - Prevent exception when filtering objects list by invalid tag +* [#7425](https://github.com/netbox-community/netbox/issues/7425) - Housekeeping command should honor zero verbosity --- diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 39f38d0b0..bc7dcc219 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -202,6 +203,9 @@ class Circuit(PrimaryModel): comments = models.TextField( blank=True ) + images = GenericRelation( + to='extras.ImageAttachment' + ) # Cache associated CircuitTerminations termination_a = models.ForeignKey( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 8331cbb10..009e1fe3f 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -38,6 +38,7 @@ __all__ = ( 'LocationForm', 'ManufacturerForm', 'PlatformForm', + 'PopulateDeviceBayForm', 'PowerFeedForm', 'PowerOutletForm', 'PowerOutletTemplateForm', @@ -52,6 +53,7 @@ __all__ = ( 'RegionForm', 'SiteForm', 'SiteGroupForm', + 'VCMemberSelectForm', 'VirtualChassisForm', ) diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index f81abd328..0e9520b36 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -39,6 +40,9 @@ class PowerPanel(PrimaryModel): name = models.CharField( max_length=100 ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 9509ec2bc..14cf34505 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable -from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT __all__ = ( @@ -45,7 +45,7 @@ class CableTable(BaseTable): verbose_name='Termination B' ) status = ChoiceFieldColumn() - length = tables.TemplateColumn( + length = TemplateColumn( template_code=CABLE_LENGTH, order_by='_abs_length' ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 306b29b09..c22e673b7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -9,7 +9,7 @@ from dcim.models import ( from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, - MarkdownColumn, TagColumn, ToggleColumn, + MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import ( CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, @@ -258,7 +258,7 @@ class CableTerminationTable(BaseTable): orderable=False, verbose_name='Cable Color' ) - cable_peer = tables.TemplateColumn( + cable_peer = TemplateColumn( accessor='_cable_peer', template_code=CABLETERMINATION, orderable=False, @@ -268,7 +268,7 @@ class CableTerminationTable(BaseTable): class PathEndpointTable(CableTerminationTable): - connection = tables.TemplateColumn( + connection = TemplateColumn( accessor='_path.last_node', template_code=CABLETERMINATION, verbose_name='Connection', @@ -470,7 +470,7 @@ class BaseInterfaceTable(BaseTable): verbose_name='IP Addresses' ) untagged_vlan = tables.Column(linkify=True) - tagged_vlans = tables.TemplateColumn( + tagged_vlans = TemplateColumn( template_code=INTERFACE_TAGGED_VLANS, orderable=False, verbose_name='Tagged VLANs' diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 65a30ac61..2f359e1b9 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -5,13 +5,11 @@ CABLETERMINATION = """ {% endif %} {{ value }} -{% else %} - — {% endif %} """ CABLE_LENGTH = """ -{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} +{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %} """ CABLE_TERMINATION_PARENT = """ @@ -63,8 +61,6 @@ INTERFACE_TAGGED_VLANS = """ {% endfor %} {% elif record.mode == 'tagged-all' %} All -{% else %} - — {% endif %} """ diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 11ffd4458..01e470e5c 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView +from extras.views import ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView from utilities.views import SlugRedirectView from . import views @@ -43,7 +43,6 @@ urlpatterns = [ path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), - path('sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Locations path('locations/', views.LocationListView.as_view(), name='location_list'), @@ -55,7 +54,6 @@ urlpatterns = [ path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), - path('locations//images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), @@ -92,7 +90,6 @@ urlpatterns = [ path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), - path('racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), @@ -229,7 +226,6 @@ urlpatterns = [ path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), path('devices//services/assign/', ServiceEditView.as_view(), name='device_service_assign'), - path('devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index 4dbfa6725..a4d617c9a 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -18,48 +18,60 @@ class Command(BaseCommand): def handle(self, *args, **options): # Clear expired authentication sessions (essentially replicating the `clearsessions` command) - self.stdout.write("[*] Clearing expired authentication sessions") - if options['verbosity'] >= 2: - self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}") + if options['verbosity']: + self.stdout.write("[*] Clearing expired authentication sessions") + if options['verbosity'] >= 2: + self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}") engine = import_module(settings.SESSION_ENGINE) try: engine.SessionStore.clear_expired() - self.stdout.write("\tSessions cleared.", self.style.SUCCESS) + if options['verbosity']: + self.stdout.write("\tSessions cleared.", self.style.SUCCESS) except NotImplementedError: - self.stdout.write( - f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support " - f"clearing sessions; skipping." - ) + if options['verbosity']: + self.stdout.write( + f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support " + f"clearing sessions; skipping." + ) # Delete expired ObjectRecords - self.stdout.write("[*] Checking for expired changelog records") + if options['verbosity']: + self.stdout.write("[*] Checking for expired changelog records") if settings.CHANGELOG_RETENTION: cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) if options['verbosity'] >= 2: - self.stdout.write(f"Retention period: {settings.CHANGELOG_RETENTION} days") + self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days") self.stdout.write(f"\tCut-off time: {cutoff}") expired_records = ObjectChange.objects.filter(time__lt=cutoff).count() if expired_records: - self.stdout.write(f"\tDeleting {expired_records} expired records... ", self.style.WARNING, ending="") - self.stdout.flush() + if options['verbosity']: + self.stdout.write( + f"\tDeleting {expired_records} expired records... ", + self.style.WARNING, + ending="" + ) + self.stdout.flush() ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS) - self.stdout.write("Done.", self.style.WARNING) - else: - self.stdout.write("\tNo expired records found.") - else: + if options['verbosity']: + self.stdout.write("Done.", self.style.SUCCESS) + elif options['verbosity']: + self.stdout.write("\tNo expired records found.", self.style.SUCCESS) + elif options['verbosity']: self.stdout.write( f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})" ) # Check for new releases (if enabled) - self.stdout.write("[*] Checking for latest release") + if options['verbosity']: + self.stdout.write("[*] Checking for latest release") if settings.RELEASE_CHECK_URL: headers = { 'Accept': 'application/vnd.github.v3+json', } try: - self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}") + if options['verbosity'] >= 2: + self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}") response = requests.get( url=settings.RELEASE_CHECK_URL, headers=headers, @@ -73,15 +85,19 @@ class Command(BaseCommand): continue releases.append((version.parse(release['tag_name']), release.get('html_url'))) latest_release = max(releases) - self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable") - self.stdout.write(f"\tLatest release: {latest_release[0]}") + if options['verbosity'] >= 2: + self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable") + if options['verbosity']: + self.stdout.write(f"\tLatest release: {latest_release[0]}", self.style.SUCCESS) # Cache the most recent release cache.set('latest_release', latest_release, None) except requests.exceptions.RequestException as exc: - self.stdout.write(f"\tRequest error: {exc}") + self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR) else: - self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set") + if options['verbosity']: + self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set") - self.stdout.write("Finished.", self.style.SUCCESS) + if options['verbosity']: + self.stdout.write("Finished.", self.style.SUCCESS) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 156b88065..fd84747b9 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -470,7 +470,6 @@ def get_scripts(use_names=False): defined name in place of the actual module name. """ scripts = OrderedDict() - # Iterate through all modules within the reports path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): @@ -478,8 +477,11 @@ def get_scripts(use_names=False): if use_names and hasattr(module, 'name'): module_name = module.name module_scripts = OrderedDict() - for name, cls in inspect.getmembers(module, is_script): - module_scripts[name] = cls + script_order = getattr(module, "script_order", ()) + ordered_scripts = [cls for cls in script_order if is_script(cls)] + unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order] + for cls in [*ordered_scripts, *unordered_scripts]: + module_scripts[cls.__name__] = cls if module_scripts: scripts[module_name] = module_scripts diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 4dafb25f2..de8ef1531 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -78,6 +78,7 @@ urlpatterns = [ kwargs={'model': models.ConfigContext}), # Image attachments + path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'), path('image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path('image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index b23dc0230..d39f50c79 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -472,22 +472,26 @@ class ImageAttachmentEditView(generic.ObjectEditView): queryset = ImageAttachment.objects.all() model_form = forms.ImageAttachmentForm - def alter_obj(self, imageattachment, request, args, kwargs): - if not imageattachment.pk: + def alter_obj(self, instance, request, args, kwargs): + if not instance.pk: # Assign the parent object based on URL kwargs - model = kwargs.get('model') - imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id']) - return imageattachment + try: + app_label, model = request.GET.get('content_type').split('.') + except (AttributeError, ValueError): + raise Http404("Content type not specified") + content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + return instance - def get_return_url(self, request, imageattachment): - return imageattachment.parent.get_absolute_url() + def get_return_url(self, request, obj=None): + return obj.parent.get_absolute_url() if obj else super().get_return_url(request) class ImageAttachmentDeleteView(generic.ObjectDeleteView): queryset = ImageAttachment.objects.all() - def get_return_url(self, request, imageattachment): - return imageattachment.parent.get_absolute_url() + def get_return_url(self, request, obj=None): + return obj.parent.get_absolute_url() if obj else super().get_return_url(request) # diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 57adbb1b8..485e4a123 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -39,15 +39,7 @@ PREFIXFLAT_LINK = """ {% if record.pk %} {{ record.prefix }} {% else %} - — -{% endif %} -""" - -PREFIX_ROLE_LINK = """ -{% if record.role %} - {{ record.role }} -{% else %} - — + {{ record.prefix }} {% endif %} """ @@ -218,8 +210,8 @@ class PrefixTable(BaseTable): linkify=True, verbose_name='VLAN' ) - role = tables.TemplateColumn( - template_code=PREFIX_ROLE_LINK + role = tables.Column( + linkify=True ) is_pool = BooleanColumn( verbose_name='Pool' @@ -264,8 +256,8 @@ class IPRangeTable(BaseTable): status = ChoiceFieldColumn( default=AVAILABLE_LABEL ) - role = tables.TemplateColumn( - template_code=PREFIX_ROLE_LINK + role = tables.Column( + linkify=True ) tenant = TenantColumn() diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 4219a8a52..fd1e92be8 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -6,7 +6,7 @@ from dcim.models import Interface from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, - ToggleColumn, + TemplateColumn, ToggleColumn, ) from virtualization.models import VMInterface from ipam.models import * @@ -35,19 +35,9 @@ VLAN_LINK = """ VLAN_PREFIXES = """ {% for prefix in record.prefixes.all %} {{ prefix }}{% if not forloop.last %}
{% endif %} -{% empty %} - — {% endfor %} """ -VLAN_ROLE_LINK = """ -{% if record.role %} - {{ record.role }} -{% else %} - — -{% endif %} -""" - VLANGROUP_ADD_VLAN = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -115,10 +105,10 @@ class VLANTable(BaseTable): status = ChoiceFieldColumn( default=AVAILABLE_LABEL ) - role = tables.TemplateColumn( - template_code=VLAN_ROLE_LINK + role = tables.Column( + linkify=True ) - prefixes = tables.TemplateColumn( + prefixes = TemplateColumn( template_code=VLAN_PREFIXES, orderable=False, verbose_name='Prefixes' @@ -190,8 +180,8 @@ class InterfaceVLANTable(BaseTable): ) tenant = TenantColumn() status = ChoiceFieldColumn() - role = tables.TemplateColumn( - template_code=VLAN_ROLE_LINK + role = tables.Column( + linkify=True ) class Meta(BaseTable.Meta): diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index bea2a6b1f..3a351a856 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -1,7 +1,7 @@ import django_tables2 as tables from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn from ipam.models import * __all__ = ( @@ -11,9 +11,7 @@ __all__ = ( VRF_TARGETS = """ {% for rt in value.all %} - {{ rt }}{% if not forloop.last %}
{% endif %} -{% empty %} - — + {{ rt }}{% if not forloop.last %}
{% endif %} {% endfor %} """ @@ -34,11 +32,11 @@ class VRFTable(BaseTable): enforce_unique = BooleanColumn( verbose_name='Unique' ) - import_targets = tables.TemplateColumn( + import_targets = TemplateColumn( template_code=VRF_TARGETS, orderable=False ) - export_targets = tables.TemplateColumn( + export_targets = TemplateColumn( template_code=VRF_TARGETS, orderable=False ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 015d47065..c24a80124 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -240,6 +240,7 @@ class AggregateView(generic.ObjectView): return { 'prefix_table': prefix_table, 'permissions': permissions, + 'bulk_querystring': f'within={instance.prefix}', 'show_available': request.GET.get('show_available', 'true') == 'true', } diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 46aa429bd..74000e978 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -230,7 +230,7 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): Overrides ListModelMixin to allow processing ExportTemplates. """ if 'export' in request.GET: - content_type = ContentType.objects.get_for_model(self.serializer_class.Meta.model) + content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) queryset = self.filter_queryset(self.get_queryset()) return et.render_to_response(queryset) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2610fa7b1..66dbdea84 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.5-dev' +VERSION = '3.0.6-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 53e20351c..3d4c60c93 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -17,7 +17,7 @@ from .admin import admin_site openapi_info = openapi.Info( title="NetBox API", - default_version='v2', + default_version='v3', description="API to access NetBox", terms_of_service="https://github.com/netbox-community/netbox", license=openapi.License(name="Apache v2 License"), @@ -59,9 +59,9 @@ _patterns = [ path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), path('api/status/', StatusView.as_view(), name='api-status'), - path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), - path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), - re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), + path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'), + path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'), + re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'), # GraphQL path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'), diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index d4be77e32..24524fad3 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index aadc19273..be7ed66c0 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/buttons/selectAll.ts b/netbox/project-static/src/buttons/selectAll.ts index 8b62ef0a0..64b98d390 100644 --- a/netbox/project-static/src/buttons/selectAll.ts +++ b/netbox/project-static/src/buttons/selectAll.ts @@ -36,7 +36,7 @@ function handleSelectAllToggle(event: Event): void { if (table !== null) { for (const element of table.querySelectorAll( - 'input[type="checkbox"][name="pk"]', + 'tr:not(.d-none) input[type="checkbox"][name="pk"]', )) { if (tableSelectAll.checked) { // Check all PK checkboxes if the select all checkbox is checked. diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 71172e901..78dc2b744 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -6,11 +6,15 @@ lang="en" data-netbox-url-name="{{ request.resolver_match.url_name }}" data-netbox-base-path="{{ settings.BASE_PATH }}" - {% if preferences|get_key:'ui.colormode' == 'dark'%} - data-netbox-color-mode="dark" - {% else %} - data-netbox-color-mode="light" - {% endif %} + {% with preferences|get_key:'ui.colormode' as color_mode %} + {% if color_mode == 'dark'%} + data-netbox-color-mode="dark" + {% elif color_mode == 'light' %} + data-netbox-color-mode="light" + {% else %} + data-netbox-color-mode="unset" + {% endif %} + {% endwith %} > @@ -23,34 +27,55 @@ {% block title %}Home{% endblock %} | NetBox {# Static resources #} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index e68465c82..b863a8a0e 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -72,6 +72,7 @@
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} + {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index aa3f834d6..9d1868e1e 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -290,22 +290,7 @@ {% endif %} -
-
- Images -
-
- {% include 'inc/image_attachments.html' with images=object.images.all %} -
- {% if perms.extras.add_imageattachment %} - - {% endif %} -
+ {% include 'inc/image_attachments_panel.html' %}
Related Devices diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 16a4f8793..b062ddcb5 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -59,22 +59,7 @@
{% include 'inc/custom_fields_panel.html' %} -
-
- Images -
-
- {% include 'inc/image_attachments.html' with images=object.images.all %} -
- {% if perms.extras.add_imageattachment %} - - {% endif %} -
+ {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index c0bfbcf25..b1367aa1e 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -39,11 +39,12 @@ + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %} {% plugin_left_page object %}
{% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %} + {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 289735200..bf9a11819 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -210,22 +210,7 @@ {% endif %} -
-
- Images -
-
- {% include 'inc/image_attachments.html' with images=object.images.all %} -
- {% if perms.extras.add_imageattachment %} - - {% endif %} -
+ {% include 'inc/image_attachments_panel.html' %}
Reservations diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 522bac60e..468d44f76 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -30,7 +30,7 @@ {% if page %}
{% for rack in page %} -
+
{{ rack.name }} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 8a348c308..1ee8cfce0 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -242,22 +242,7 @@ {% endif %}
-
-
- Images -
-
- {% include 'inc/image_attachments.html' with images=object.images.all %} -
- {% if perms.extras.add_imageattachment %} - - {% endif %} -
+ {% include 'inc/image_attachments_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/virtualchassis_add_member.html b/netbox/templates/dcim/virtualchassis_add_member.html index e8c0dac59..17ffd64d9 100644 --- a/netbox/templates/dcim/virtualchassis_add_member.html +++ b/netbox/templates/dcim/virtualchassis_add_member.html @@ -4,25 +4,19 @@ {% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %} {% block content %} -
+ {% csrf_token %} -
-
-
-
Add New Member
-
- {% render_form member_select_form %} - {% render_form membership_form %} -
-
+
+
Add New Member
+
+ {% render_form member_select_form %} + {% render_form membership_form %}
-
-
- Cancel - - -
+
+ Cancel + +
{% endblock %} diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html deleted file mode 100644 index 5ec47af7e..000000000 --- a/netbox/templates/inc/image_attachments.html +++ /dev/null @@ -1,38 +0,0 @@ -{% load helpers %} - -{% if images %} - - - - - - - - {% for attachment in images %} - - - - - - - {% endfor %} -
NameSizeCreated
- - {{ attachment }} - {{ attachment.size|filesizeformat }}{{ attachment.created|annotated_date }} - {% if perms.extras.change_imageattachment %} - - - - {% endif %} - {% if perms.extras.delete_imageattachment %} - - - - {% endif %} -
-{% else %} -
- None -
-{% endif %} diff --git a/netbox/templates/inc/image_attachments_panel.html b/netbox/templates/inc/image_attachments_panel.html new file mode 100644 index 000000000..ca7312901 --- /dev/null +++ b/netbox/templates/inc/image_attachments_panel.html @@ -0,0 +1,52 @@ +{% load helpers %} + +
+
+ Images +
+
+ {% with images=object.images.all %} + {% if images.exists %} + + + + + + + + {% for attachment in images %} + + + + + + + {% endfor %} +
NameSizeCreated
+ + {{ attachment }} + {{ attachment.size|filesizeformat }}{{ attachment.created|annotated_date }} + {% if perms.extras.change_imageattachment %} + + + + {% endif %} + {% if perms.extras.delete_imageattachment %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.extras.add_imageattachment %} + + {% endif %} +
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 29aeec1ef..c254d9d63 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -75,8 +75,8 @@
-
- {% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %} -
+
+ {% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %} +
{% endblock %} diff --git a/netbox/templates/ipam/prefix/base.html b/netbox/templates/ipam/prefix/base.html index 35f36e1ec..7d13eb1aa 100644 --- a/netbox/templates/ipam/prefix/base.html +++ b/netbox/templates/ipam/prefix/base.html @@ -25,11 +25,9 @@ Child Ranges {% badge object.get_child_ranges.count %} - {% if perms.ipam.view_ipaddress and object.status != 'container' %} - - {% endif %} + {% endblock %} diff --git a/netbox/templates/utilities/obj_table.html b/netbox/templates/utilities/obj_table.html index 99ab5c0c7..10337ff43 100644 --- a/netbox/templates/utilities/obj_table.html +++ b/netbox/templates/utilities/obj_table.html @@ -24,7 +24,7 @@
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 0a8e8c3ba..c8d0a0e43 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -29,13 +29,18 @@ class BaseTable(tables.Table): 'class': 'table table-hover object-list', } - def __init__(self, *args, user=None, **kwargs): + def __init__(self, *args, user=None, extra_columns=None, **kwargs): # Add custom field columns obj_type = ContentType.objects.get_for_model(self._meta.model) - for cf in CustomField.objects.filter(content_types=obj_type): - self.base_columns[f'cf_{cf.name}'] = CustomFieldColumn(cf) + cf_columns = [ + (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) + ] + if extra_columns is not None: + extra_columns.extend(cf_columns) + else: + extra_columns = cf_columns - super().__init__(*args, **kwargs) + super().__init__(*args, extra_columns=extra_columns, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: @@ -50,17 +55,22 @@ class BaseTable(tables.Table): # Apply custom column ordering for user if user is not None and not isinstance(user, AnonymousUser): - columns = user.config.get(f"tables.{self.__class__.__name__}.columns") - if columns: + selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") + if selected_columns: pk = self.base_columns.pop('pk', None) actions = self.base_columns.pop('actions', None) - for name, column in self.base_columns.items(): - if name in columns: + for name, column in self.columns.items(): + if name in selected_columns: self.columns.show(name) else: self.columns.hide(name) - self.sequence = [c for c in columns if c in self.base_columns] + # Rearrange the sequence to list selected columns first, followed by all remaining columns + # TODO: There's probably a more clever way to accomplish this + self.sequence = [ + *[c for c in selected_columns if c in self.columns.names()], + *[c for c in self.columns.names() if c not in selected_columns] + ] # Always include PK and actions column, if defined on the table if pk: @@ -111,6 +121,16 @@ class BaseTable(tables.Table): def selected_columns(self): return self._get_columns(visible=True) + @property + def objects_count(self): + """ + Return the total number of real objects represented by the Table. This is useful when dealing with + prefixes/IP addresses/etc., where some table rows may represent available address space. + """ + if not hasattr(self, '_objects_count'): + self._objects_count = sum(1 for obj in self.data if getattr(obj, 'pk')) + return self._objects_count + # # Table columns @@ -157,6 +177,25 @@ class BooleanColumn(tables.Column): return str(value) +class TemplateColumn(tables.TemplateColumn): + """ + Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string. + """ + PLACEHOLDER = mark_safe('—') + + def render(self, *args, **kwargs): + ret = super().render(*args, **kwargs) + if not ret.strip(): + return self.PLACEHOLDER + return ret + + def value(self, **kwargs): + ret = super().value(**kwargs) + if ret == self.PLACEHOLDER: + return '' + return ret + + class ButtonsColumn(tables.TemplateColumn): """ Render edit, delete, and changelog buttons for an object. diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 532eea19b..a900d59e2 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -398,6 +398,9 @@ def applied_filters(form, query_params): applied_filters = [] for filter_name in form.changed_data: + if filter_name not in form.cleaned_data: + continue + querydict = query_params.copy() if filter_name not in querydict: continue diff --git a/requirements.txt b/requirements.txt index 5b9c10e87..5f5e158ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,10 +18,13 @@ gunicorn==20.1.0 Jinja2==3.0.1 Markdown==3.3.4 markdown-include==0.6.0 -mkdocs-material==7.3.0 +mkdocs-material==7.3.1 netaddr==0.8.0 Pillow==8.3.2 psycopg2-binary==2.9.1 PyYAML==5.4.1 svgwrite==1.4.1 tablib==3.0.0 + +# Workaround for #7401 +jsonschema==3.2.0 diff --git a/upgrade.sh b/upgrade.sh index 6ac8d6122..67b8aaa89 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -61,22 +61,6 @@ else echo "Skipping local dependencies (local_requirements.txt not found)" fi -# Test schema migrations integrity -COMMAND="python3 netbox/manage.py showmigrations" -eval $COMMAND > /dev/null 2>&1 || { - echo "--------------------------------------------------------------------" - echo "ERROR: Database schema migrations are out of synchronization. (No" - echo "data has been lost.) If attempting to upgrade to NetBox v3.0 or" - echo "later, first upgrade to a v2.11 release to ensure schema migrations" - echo "have been correctly prepared. For further detail on the exact error," - echo "run the following commands:" - echo "" - echo " source ${VIRTUALENV}/bin/activate" - echo " ${COMMAND}" - echo "--------------------------------------------------------------------" - exit 1 -} - # Apply any database migrations COMMAND="python3 netbox/manage.py migrate" echo "Applying database migrations ($COMMAND)..."