Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-10-04 14:19:16 -04:00
commit 9c8432cf13
44 changed files with 330 additions and 286 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most 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/) 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.) before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.4 placeholder: v3.0.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.0.4 placeholder: v3.0.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -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. 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 ## Module Attributes
### `name` ### `name`

View File

@ -11,7 +11,7 @@ The following sections detail how to set up a new instance of NetBox:
5. [HTTP server](5-http-server.md) 5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional) 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.
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> <iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

View File

@ -1,6 +1,28 @@
# NetBox v3.0 # 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
--- ---

View File

@ -1,3 +1,4 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -202,6 +203,9 @@ class Circuit(PrimaryModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
images = GenericRelation(
to='extras.ImageAttachment'
)
# Cache associated CircuitTerminations # Cache associated CircuitTerminations
termination_a = models.ForeignKey( termination_a = models.ForeignKey(

View File

@ -38,6 +38,7 @@ __all__ = (
'LocationForm', 'LocationForm',
'ManufacturerForm', 'ManufacturerForm',
'PlatformForm', 'PlatformForm',
'PopulateDeviceBayForm',
'PowerFeedForm', 'PowerFeedForm',
'PowerOutletForm', 'PowerOutletForm',
'PowerOutletTemplateForm', 'PowerOutletTemplateForm',
@ -52,6 +53,7 @@ __all__ = (
'RegionForm', 'RegionForm',
'SiteForm', 'SiteForm',
'SiteGroupForm', 'SiteGroupForm',
'VCMemberSelectForm',
'VirtualChassisForm', 'VirtualChassisForm',
) )

View File

@ -1,3 +1,4 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -39,6 +40,9 @@ class PowerPanel(PrimaryModel):
name = models.CharField( name = models.CharField(
max_length=100 max_length=100
) )
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()

View File

@ -2,7 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Cable 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 from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
__all__ = ( __all__ = (
@ -45,7 +45,7 @@ class CableTable(BaseTable):
verbose_name='Termination B' verbose_name='Termination B'
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
length = tables.TemplateColumn( length = TemplateColumn(
template_code=CABLE_LENGTH, template_code=CABLE_LENGTH,
order_by='_abs_length' order_by='_abs_length'
) )

View File

@ -9,7 +9,7 @@ from dcim.models import (
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, ToggleColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
) )
from .template_code import ( from .template_code import (
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@ -258,7 +258,7 @@ class CableTerminationTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Cable Color' verbose_name='Cable Color'
) )
cable_peer = tables.TemplateColumn( cable_peer = TemplateColumn(
accessor='_cable_peer', accessor='_cable_peer',
template_code=CABLETERMINATION, template_code=CABLETERMINATION,
orderable=False, orderable=False,
@ -268,7 +268,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable): class PathEndpointTable(CableTerminationTable):
connection = tables.TemplateColumn( connection = TemplateColumn(
accessor='_path.last_node', accessor='_path.last_node',
template_code=CABLETERMINATION, template_code=CABLETERMINATION,
verbose_name='Connection', verbose_name='Connection',
@ -470,7 +470,7 @@ class BaseInterfaceTable(BaseTable):
verbose_name='IP Addresses' verbose_name='IP Addresses'
) )
untagged_vlan = tables.Column(linkify=True) untagged_vlan = tables.Column(linkify=True)
tagged_vlans = tables.TemplateColumn( tagged_vlans = TemplateColumn(
template_code=INTERFACE_TAGGED_VLANS, template_code=INTERFACE_TAGGED_VLANS,
orderable=False, orderable=False,
verbose_name='Tagged VLANs' verbose_name='Tagged VLANs'

View File

@ -5,13 +5,11 @@ CABLETERMINATION = """
<i class="mdi mdi-chevron-right"></i> <i class="mdi mdi-chevron-right"></i>
{% endif %} {% endif %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a> <a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
&mdash;
{% endif %} {% endif %}
""" """
CABLE_LENGTH = """ CABLE_LENGTH = """
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %} {% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
""" """
CABLE_TERMINATION_PARENT = """ CABLE_TERMINATION_PARENT = """
@ -63,8 +61,6 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %} {% endfor %}
{% elif record.mode == 'tagged-all' %} {% elif record.mode == 'tagged-all' %}
All All
{% else %}
&mdash;
{% endif %} {% endif %}
""" """

View File

@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView from extras.views import ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView from ipam.views import ServiceEditView
from utilities.views import SlugRedirectView from utilities.views import SlugRedirectView
from . import views from . import views
@ -43,7 +43,6 @@ urlpatterns = [
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'), path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Locations # Locations
path('locations/', views.LocationListView.as_view(), name='location_list'), path('locations/', views.LocationListView.as_view(), name='location_list'),
@ -55,7 +54,6 @@ urlpatterns = [
path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'), path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
path('locations/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}),
# Rack roles # Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
@ -92,7 +90,6 @@ urlpatterns = [
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'), path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers # Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@ -229,7 +226,6 @@ urlpatterns = [
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'), path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'), path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports # Console ports
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),

View File

@ -18,48 +18,60 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
# Clear expired authentication sessions (essentially replicating the `clearsessions` command) # Clear expired authentication sessions (essentially replicating the `clearsessions` command)
self.stdout.write("[*] Clearing expired authentication sessions") if options['verbosity']:
if options['verbosity'] >= 2: self.stdout.write("[*] Clearing expired authentication sessions")
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}") if options['verbosity'] >= 2:
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
engine = import_module(settings.SESSION_ENGINE) engine = import_module(settings.SESSION_ENGINE)
try: try:
engine.SessionStore.clear_expired() 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: except NotImplementedError:
self.stdout.write( if options['verbosity']:
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support " self.stdout.write(
f"clearing sessions; skipping." f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
) f"clearing sessions; skipping."
)
# Delete expired ObjectRecords # 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: if settings.CHANGELOG_RETENTION:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
if options['verbosity'] >= 2: 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}") self.stdout.write(f"\tCut-off time: {cutoff}")
expired_records = ObjectChange.objects.filter(time__lt=cutoff).count() expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
if expired_records: if expired_records:
self.stdout.write(f"\tDeleting {expired_records} expired records... ", self.style.WARNING, ending="") if options['verbosity']:
self.stdout.flush() 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) ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
self.stdout.write("Done.", self.style.WARNING) if options['verbosity']:
else: self.stdout.write("Done.", self.style.SUCCESS)
self.stdout.write("\tNo expired records found.") elif options['verbosity']:
else: self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
elif options['verbosity']:
self.stdout.write( self.stdout.write(
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})" f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
) )
# Check for new releases (if enabled) # 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: if settings.RELEASE_CHECK_URL:
headers = { headers = {
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
} }
try: 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( response = requests.get(
url=settings.RELEASE_CHECK_URL, url=settings.RELEASE_CHECK_URL,
headers=headers, headers=headers,
@ -73,15 +85,19 @@ class Command(BaseCommand):
continue continue
releases.append((version.parse(release['tag_name']), release.get('html_url'))) releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases) latest_release = max(releases)
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable") if options['verbosity'] >= 2:
self.stdout.write(f"\tLatest release: {latest_release[0]}") 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 the most recent release
cache.set('latest_release', latest_release, None) cache.set('latest_release', latest_release, None)
except requests.exceptions.RequestException as exc: except requests.exceptions.RequestException as exc:
self.stdout.write(f"\tRequest error: {exc}") self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
else: 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)

View File

@ -470,7 +470,6 @@ def get_scripts(use_names=False):
defined name in place of the actual module name. defined name in place of the actual module name.
""" """
scripts = OrderedDict() scripts = OrderedDict()
# Iterate through all modules within the reports path. These are the user-created files in which reports are # Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined. # defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): 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'): if use_names and hasattr(module, 'name'):
module_name = module.name module_name = module.name
module_scripts = OrderedDict() module_scripts = OrderedDict()
for name, cls in inspect.getmembers(module, is_script): script_order = getattr(module, "script_order", ())
module_scripts[name] = cls 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: if module_scripts:
scripts[module_name] = module_scripts scripts[module_name] = module_scripts

View File

@ -78,6 +78,7 @@ urlpatterns = [
kwargs={'model': models.ConfigContext}), kwargs={'model': models.ConfigContext}),
# Image attachments # Image attachments
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),

View File

@ -472,22 +472,26 @@ class ImageAttachmentEditView(generic.ObjectEditView):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
model_form = forms.ImageAttachmentForm model_form = forms.ImageAttachmentForm
def alter_obj(self, imageattachment, request, args, kwargs): def alter_obj(self, instance, request, args, kwargs):
if not imageattachment.pk: if not instance.pk:
# Assign the parent object based on URL kwargs # Assign the parent object based on URL kwargs
model = kwargs.get('model') try:
imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id']) app_label, model = request.GET.get('content_type').split('.')
return imageattachment 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): def get_return_url(self, request, obj=None):
return imageattachment.parent.get_absolute_url() return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
class ImageAttachmentDeleteView(generic.ObjectDeleteView): class ImageAttachmentDeleteView(generic.ObjectDeleteView):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
def get_return_url(self, request, imageattachment): def get_return_url(self, request, obj=None):
return imageattachment.parent.get_absolute_url() return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
# #

View File

@ -39,15 +39,7 @@ PREFIXFLAT_LINK = """
{% if record.pk %} {% if record.pk %}
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a> <a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
{% else %} {% else %}
&mdash; {{ record.prefix }}
{% endif %}
"""
PREFIX_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %} {% endif %}
""" """
@ -218,8 +210,8 @@ class PrefixTable(BaseTable):
linkify=True, linkify=True,
verbose_name='VLAN' verbose_name='VLAN'
) )
role = tables.TemplateColumn( role = tables.Column(
template_code=PREFIX_ROLE_LINK linkify=True
) )
is_pool = BooleanColumn( is_pool = BooleanColumn(
verbose_name='Pool' verbose_name='Pool'
@ -264,8 +256,8 @@ class IPRangeTable(BaseTable):
status = ChoiceFieldColumn( status = ChoiceFieldColumn(
default=AVAILABLE_LABEL default=AVAILABLE_LABEL
) )
role = tables.TemplateColumn( role = tables.Column(
template_code=PREFIX_ROLE_LINK linkify=True
) )
tenant = TenantColumn() tenant = TenantColumn()

View File

@ -6,7 +6,7 @@ from dcim.models import Interface
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
ToggleColumn, TemplateColumn, ToggleColumn,
) )
from virtualization.models import VMInterface from virtualization.models import VMInterface
from ipam.models import * from ipam.models import *
@ -35,19 +35,9 @@ VLAN_LINK = """
VLAN_PREFIXES = """ VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %} {% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %} <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %} {% endfor %}
""" """
VLAN_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
VLANGROUP_ADD_VLAN = """ VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %} {% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %} {% if next_vid and perms.ipam.add_vlan %}
@ -115,10 +105,10 @@ class VLANTable(BaseTable):
status = ChoiceFieldColumn( status = ChoiceFieldColumn(
default=AVAILABLE_LABEL default=AVAILABLE_LABEL
) )
role = tables.TemplateColumn( role = tables.Column(
template_code=VLAN_ROLE_LINK linkify=True
) )
prefixes = tables.TemplateColumn( prefixes = TemplateColumn(
template_code=VLAN_PREFIXES, template_code=VLAN_PREFIXES,
orderable=False, orderable=False,
verbose_name='Prefixes' verbose_name='Prefixes'
@ -190,8 +180,8 @@ class InterfaceVLANTable(BaseTable):
) )
tenant = TenantColumn() tenant = TenantColumn()
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
role = tables.TemplateColumn( role = tables.Column(
template_code=VLAN_ROLE_LINK linkify=True
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):

View File

@ -1,7 +1,7 @@
import django_tables2 as tables import django_tables2 as tables
from tenancy.tables import TenantColumn 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 * from ipam.models import *
__all__ = ( __all__ = (
@ -11,9 +11,7 @@ __all__ = (
VRF_TARGETS = """ VRF_TARGETS = """
{% for rt in value.all %} {% for rt in value.all %}
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %} <a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %} {% endfor %}
""" """
@ -34,11 +32,11 @@ class VRFTable(BaseTable):
enforce_unique = BooleanColumn( enforce_unique = BooleanColumn(
verbose_name='Unique' verbose_name='Unique'
) )
import_targets = tables.TemplateColumn( import_targets = TemplateColumn(
template_code=VRF_TARGETS, template_code=VRF_TARGETS,
orderable=False orderable=False
) )
export_targets = tables.TemplateColumn( export_targets = TemplateColumn(
template_code=VRF_TARGETS, template_code=VRF_TARGETS,
orderable=False orderable=False
) )

View File

@ -240,6 +240,7 @@ class AggregateView(generic.ObjectView):
return { return {
'prefix_table': prefix_table, 'prefix_table': prefix_table,
'permissions': permissions, 'permissions': permissions,
'bulk_querystring': f'within={instance.prefix}',
'show_available': request.GET.get('show_available', 'true') == 'true', 'show_available': request.GET.get('show_available', 'true') == 'true',
} }

View File

@ -230,7 +230,7 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
Overrides ListModelMixin to allow processing ExportTemplates. Overrides ListModelMixin to allow processing ExportTemplates.
""" """
if 'export' in request.GET: 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']) et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
return et.render_to_response(queryset) return et.render_to_response(queryset)

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '3.0.5-dev' VERSION = '3.0.6-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -17,7 +17,7 @@ from .admin import admin_site
openapi_info = openapi.Info( openapi_info = openapi.Info(
title="NetBox API", title="NetBox API",
default_version='v2', default_version='v3',
description="API to access NetBox", description="API to access NetBox",
terms_of_service="https://github.com/netbox-community/netbox", terms_of_service="https://github.com/netbox-community/netbox",
license=openapi.License(name="Apache v2 License"), license=openapi.License(name="Apache v2 License"),
@ -59,9 +59,9 @@ _patterns = [
path('api/users/', include('users.api.urls')), path('api/users/', include('users.api.urls')),
path('api/virtualization/', include('virtualization.api.urls')), path('api/virtualization/', include('virtualization.api.urls')),
path('api/status/', StatusView.as_view(), name='api-status'), path('api/status/', StatusView.as_view(), name='api-status'),
path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'),
# GraphQL # GraphQL
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'), path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),

Binary file not shown.

Binary file not shown.

View File

@ -36,7 +36,7 @@ function handleSelectAllToggle(event: Event): void {
if (table !== null) { if (table !== null) {
for (const element of table.querySelectorAll<HTMLInputElement>( for (const element of table.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"][name="pk"]', 'tr:not(.d-none) input[type="checkbox"][name="pk"]',
)) { )) {
if (tableSelectAll.checked) { if (tableSelectAll.checked) {
// Check all PK checkboxes if the select all checkbox is checked. // Check all PK checkboxes if the select all checkbox is checked.

View File

@ -6,11 +6,15 @@
lang="en" lang="en"
data-netbox-url-name="{{ request.resolver_match.url_name }}" data-netbox-url-name="{{ request.resolver_match.url_name }}"
data-netbox-base-path="{{ settings.BASE_PATH }}" data-netbox-base-path="{{ settings.BASE_PATH }}"
{% if preferences|get_key:'ui.colormode' == 'dark'%} {% with preferences|get_key:'ui.colormode' as color_mode %}
data-netbox-color-mode="dark" {% if color_mode == 'dark'%}
{% else %} data-netbox-color-mode="dark"
data-netbox-color-mode="light" {% elif color_mode == 'light' %}
{% endif %} data-netbox-color-mode="light"
{% else %}
data-netbox-color-mode="unset"
{% endif %}
{% endwith %}
> >
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -23,34 +27,55 @@
<title>{% block title %}Home{% endblock %} | NetBox</title> <title>{% block title %}Home{% endblock %} | NetBox</title>
<script type="text/javascript"> <script type="text/javascript">
/** /**
* Determine the best initial color mode to use prior to rendering. * Set the color mode on the `<html/>` element and in local storage.
*/ */
(function() { function setMode(mode) {
// Browser prefers dark color scheme. document.documentElement.setAttribute("data-netbox-color-mode", mode);
var preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches; localStorage.setItem("netbox-color-mode", mode);
// Browser prefers light color scheme. }
var preferLight = window.matchMedia('(prefers-color-scheme: light)').matches; /**
// Client NetBox color-mode override. * Determine the best initial color mode to use prior to rendering.
var clientMode = localStorage.getItem('netbox-color-mode'); */
// NetBox server-rendered value. (function () {
var serverMode = document.documentElement.getAttribute('data-netbox-color-mode'); try {
// Browser prefers dark color scheme.
var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Browser prefers light color scheme.
var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
// Client NetBox color-mode override.
var clientMode = localStorage.getItem("netbox-color-mode");
// NetBox server-rendered value.
var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
// If the client mode is not set but the server mode is, use the server mode.
return setMode(serverMode);
}
if (clientMode !== null && clientMode !== serverMode) {
// If the client mode is set and is different than the server mode, use the client mode
// over the server mode, as it should be more recent.
return setMode(clientMode);
}
if (clientMode === serverMode) {
// If the client and server modes match, use that value.
return setMode(clientMode);
}
if (preferDark && serverMode === "unset") {
// If the server mode is not set but the browser prefers dark mode, use dark mode.
return setMode("dark");
}
if (preferLight && serverMode === "unset") {
// If the server mode is not set but the browser prefers light mode, use light mode.
return setMode("light");
}
} catch (error) {
// In the event of an error, log it to the console and set the mode to light mode.
console.error(error);
}
return setMode("light");
})();
if ((clientMode !== null) && (clientMode !== serverMode)) {
// If the client mode is set, use its value over the server's value.
return document.documentElement.setAttribute('data-netbox-color-mode', clientMode);
}
if (preferDark && serverMode === 'light') {
// If the client value matches the server value, the browser preferrs dark-mode, but
// the server value doesn't match the browser preference, use dark mode.
return document.documentElement.setAttribute('data-netbox-color-mode', 'dark');
}
if (preferLight && serverMode === 'dark') {
// If the client value matches the server value, the browser preferrs dark-mode, but
// the server value doesn't match the browser preference, use light mode.
return document.documentElement.setAttribute('data-netbox-color-mode', 'light');
}
})();
</script> </script>
{# Static resources #} {# Static resources #}

View File

@ -72,6 +72,7 @@
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% 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 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/image_attachments_panel.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -290,22 +290,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="card"> {% include 'inc/image_attachments_panel.html' %}
<h5 class="card-header">
Images
</h5>
<div class="card-body">
{% include 'inc/image_attachments.html' with images=object.images.all %}
</div>
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Attach an Image
</a>
</div>
{% endif %}
</div>
<div class="card noprint"> <div class="card noprint">
<h5 class="card-header"> <h5 class="card-header">
Related Devices Related Devices

View File

@ -59,22 +59,7 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/custom_fields_panel.html' %} {% include 'inc/custom_fields_panel.html' %}
<div class="card"> {% include 'inc/image_attachments_panel.html' %}
<h5 class="card-header">
Images
</h5>
<div class="card-body">
{% include 'inc/image_attachments.html' with images=object.images.all %}
</div>
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:location_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Attach an Image
</a>
</div>
{% endif %}
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -39,11 +39,12 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/custom_fields_panel.html' %} {% 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 %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -210,22 +210,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="card"> {% include 'inc/image_attachments_panel.html' %}
<h5 class="card-header">
Images
</h5>
<div class="card-body">
{% include 'inc/image_attachments.html' with images=object.images.all %}
</div>
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:rack_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
Attach an Image
</a>
</div>
{% endif %}
</div>
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Reservations Reservations

View File

@ -30,7 +30,7 @@
{% if page %} {% if page %}
<div style="white-space: nowrap; overflow-x: scroll;"> <div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %} {% for rack in page %}
<div style="display: inline-block; margin-right: 12px; width: 254px"> <div style="display: inline-block; margin-right: 12px">
<div style="margin-left: 30px"> <div style="margin-left: 30px">
<div class="text-center"> <div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong> <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>

View File

@ -242,22 +242,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="card"> {% include 'inc/image_attachments_panel.html' %}
<h5 class="card-header">
Images
</h5>
<div class="card-body">
{% include 'inc/image_attachments.html' with images=object.images.all %}
</div>
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:site_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
Attach an image
</a>
</div>
{% endif %}
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -4,25 +4,19 @@
{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %} {% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}
{% block content %} {% block content %}
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal"> <form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
{% csrf_token %} {% csrf_token %}
<div class="row mb-3"> <div class="card">
<div class="col col-md-6"> <h5 class="card-header">Add New Member</h5>
<div class="card"> <div class="card-body">
<h5 class="card-header">Add New Member</h5> {% render_form member_select_form %}
<div class="card-body"> {% render_form membership_form %}
{% render_form member_select_form %}
{% render_form membership_form %}
</div>
</div>
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="text-end my-3">
<div class="col col-md-6 text-end"> <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a> <button type="submit" name="_addanother" class="btn btn-outline-primary">Add Another</button>
<button type="submit" name="_addanother" class="btn btn-outline-primary">Add Another</button> <button type="submit" name="_save" class="btn btn-primary">Save</button>
<button type="submit" name="_save" class="btn btn-primary">Save</button>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -1,38 +0,0 @@
{% load helpers %}
{% if images %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Size</th>
<th>Created</th>
<th></th>
</tr>
{% for attachment in images %}
<tr{% if not attachment.size %} class="table-danger"{% endif %}>
<td>
<i class="mdi mdi-file-image-outline"></i>
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
</td>
<td>{{ attachment.size|filesizeformat }}</td>
<td>{{ attachment.created|annotated_date }}</td>
<td class="text-end noprint">
{% if perms.extras.change_imageattachment %}
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.extras.delete_imageattachment %}
<a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">
None
</div>
{% endif %}

View File

@ -0,0 +1,52 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">
Images
</h5>
<div class="card-body">
{% with images=object.images.all %}
{% if images.exists %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Size</th>
<th>Created</th>
<th></th>
</tr>
{% for attachment in images %}
<tr{% if not attachment.size %} class="table-danger"{% endif %}>
<td>
<i class="mdi mdi-file-image-outline"></i>
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
</td>
<td>{{ attachment.size|filesizeformat }}</td>
<td>{{ attachment.created|annotated_date }}</td>
<td class="text-end noprint">
{% if perms.extras.change_imageattachment %}
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.extras.delete_imageattachment %}
<a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
{% endwith %}
</div>
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Attach an image
</a>
</div>
{% endif %}
</div>

View File

@ -75,8 +75,8 @@
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
{% 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' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -25,11 +25,9 @@
Child Ranges {% badge object.get_child_ranges.count %} Child Ranges {% badge object.get_child_ranges.count %}
</a> </a>
</li> </li>
{% if perms.ipam.view_ipaddress and object.status != 'container' %} <li role="presentation" class="nav-item">
<li role="presentation" class="nav-item"> <a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">
<a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}"> IP Addresses {% badge object.get_child_ips.count %}
IP Addresses {% badge object.get_child_ips.count %} </a>
</a> </li>
</li>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -24,7 +24,7 @@
<div class="form-check"> <div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" /> <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label"> <label for="select-all" class="form-check-label">
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query Select <strong>all {{ table.objects_count }} {{ table.data.verbose_name_plural }}</strong> matching query
</label> </label>
</div> </div>
</div> </div>

View File

@ -29,13 +29,18 @@ class BaseTable(tables.Table):
'class': 'table table-hover object-list', '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 # Add custom field columns
obj_type = ContentType.objects.get_for_model(self._meta.model) obj_type = ContentType.objects.get_for_model(self._meta.model)
for cf in CustomField.objects.filter(content_types=obj_type): cf_columns = [
self.base_columns[f'cf_{cf.name}'] = CustomFieldColumn(cf) (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 # Set default empty_text if none was provided
if self.empty_text is None: if self.empty_text is None:
@ -50,17 +55,22 @@ class BaseTable(tables.Table):
# Apply custom column ordering for user # Apply custom column ordering for user
if user is not None and not isinstance(user, AnonymousUser): if user is not None and not isinstance(user, AnonymousUser):
columns = user.config.get(f"tables.{self.__class__.__name__}.columns") selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
if columns: if selected_columns:
pk = self.base_columns.pop('pk', None) pk = self.base_columns.pop('pk', None)
actions = self.base_columns.pop('actions', None) actions = self.base_columns.pop('actions', None)
for name, column in self.base_columns.items(): for name, column in self.columns.items():
if name in columns: if name in selected_columns:
self.columns.show(name) self.columns.show(name)
else: else:
self.columns.hide(name) 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 # Always include PK and actions column, if defined on the table
if pk: if pk:
@ -111,6 +121,16 @@ class BaseTable(tables.Table):
def selected_columns(self): def selected_columns(self):
return self._get_columns(visible=True) 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 # Table columns
@ -157,6 +177,25 @@ class BooleanColumn(tables.Column):
return str(value) 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('&mdash;')
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): class ButtonsColumn(tables.TemplateColumn):
""" """
Render edit, delete, and changelog buttons for an object. Render edit, delete, and changelog buttons for an object.

View File

@ -398,6 +398,9 @@ def applied_filters(form, query_params):
applied_filters = [] applied_filters = []
for filter_name in form.changed_data: for filter_name in form.changed_data:
if filter_name not in form.cleaned_data:
continue
querydict = query_params.copy() querydict = query_params.copy()
if filter_name not in querydict: if filter_name not in querydict:
continue continue

View File

@ -18,10 +18,13 @@ gunicorn==20.1.0
Jinja2==3.0.1 Jinja2==3.0.1
Markdown==3.3.4 Markdown==3.3.4
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==7.3.0 mkdocs-material==7.3.1
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.3.2 Pillow==8.3.2
psycopg2-binary==2.9.1 psycopg2-binary==2.9.1
PyYAML==5.4.1 PyYAML==5.4.1
svgwrite==1.4.1 svgwrite==1.4.1
tablib==3.0.0 tablib==3.0.0
# Workaround for #7401
jsonschema==3.2.0

View File

@ -61,22 +61,6 @@ else
echo "Skipping local dependencies (local_requirements.txt not found)" echo "Skipping local dependencies (local_requirements.txt not found)"
fi 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 # Apply any database migrations
COMMAND="python3 netbox/manage.py migrate" COMMAND="python3 netbox/manage.py migrate"
echo "Applying database migrations ($COMMAND)..." echo "Applying database migrations ($COMMAND)..."