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
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

View File

@ -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

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.
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`

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)
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>

View File

@ -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
---

View File

@ -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(

View File

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

View File

@ -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()

View File

@ -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'
)

View File

@ -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'

View File

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

View File

@ -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/<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>/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
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>/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:object_id>/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/<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>/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
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>/config/', views.DeviceConfigView.as_view(), name='device_config'),
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
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),

View File

@ -18,40 +18,51 @@ class Command(BaseCommand):
def handle(self, *args, **options):
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
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()
if options['verbosity']:
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
except NotImplementedError:
if options['verbosity']:
self.stdout.write(
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)
# Delete expired ObjectRecords
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="")
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)
if options['verbosity']:
self.stdout.write("[*] Checking for latest release")
if settings.RELEASE_CHECK_URL:
headers = {
@ -59,6 +70,7 @@ class Command(BaseCommand):
}
try:
if options['verbosity'] >= 2:
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
response = requests.get(
url=settings.RELEASE_CHECK_URL,
@ -73,15 +85,19 @@ class Command(BaseCommand):
continue
releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases)
if options['verbosity'] >= 2:
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
self.stdout.write(f"\tLatest release: {latest_release[0]}")
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:
if options['verbosity']:
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
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.
"""
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

View File

@ -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/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
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()
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)
#

View File

@ -39,15 +39,7 @@ PREFIXFLAT_LINK = """
{% if record.pk %}
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
{% else %}
&mdash;
{% endif %}
"""
PREFIX_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{{ 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()

View File

@ -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 %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% 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 = """
{% 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):

View File

@ -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__ = (
@ -12,8 +12,6 @@ __all__ = (
VRF_TARGETS = """
{% for rt in value.all %}
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% 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
)

View File

@ -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',
}

View File

@ -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)

View File

@ -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()

View File

@ -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<format>.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<format>.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'),

Binary file not shown.

Binary file not shown.

View File

@ -36,7 +36,7 @@ function handleSelectAllToggle(event: Event): void {
if (table !== null) {
for (const element of table.querySelectorAll<HTMLInputElement>(
'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.

View File

@ -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'%}
{% with preferences|get_key:'ui.colormode' as color_mode %}
{% if color_mode == 'dark'%}
data-netbox-color-mode="dark"
{% else %}
{% elif color_mode == 'light' %}
data-netbox-color-mode="light"
{% else %}
data-netbox-color-mode="unset"
{% endif %}
{% endwith %}
>
<head>
<meta charset="UTF-8" />
@ -23,34 +27,55 @@
<title>{% block title %}Home{% endblock %} | NetBox</title>
<script type="text/javascript">
/**
* Set the color mode on the `<html/>` element and in local storage.
*/
function setMode(mode) {
document.documentElement.setAttribute("data-netbox-color-mode", mode);
localStorage.setItem("netbox-color-mode", mode);
}
/**
* Determine the best initial color mode to use prior to rendering.
*/
(function() {
(function () {
try {
// Browser prefers dark color scheme.
var preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Browser prefers light color scheme.
var preferLight = window.matchMedia('(prefers-color-scheme: light)').matches;
var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
// Client NetBox color-mode override.
var clientMode = localStorage.getItem('netbox-color-mode');
var clientMode = localStorage.getItem("netbox-color-mode");
// NetBox server-rendered value.
var serverMode = document.documentElement.getAttribute('data-netbox-color-mode');
var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
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 (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 (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 (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 (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');
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");
})();
</script>
{# Static resources #}

View File

@ -72,6 +72,7 @@
<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_z side='Z' %}
{% include 'inc/image_attachments_panel.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@ -290,22 +290,7 @@
</div>
{% endif %}
</div>
<div class="card">
<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>
{% include 'inc/image_attachments_panel.html' %}
<div class="card noprint">
<h5 class="card-header">
Related Devices

View File

@ -59,22 +59,7 @@
</div>
<div class="col col-md-6">
{% include 'inc/custom_fields_panel.html' %}
<div class="card">
<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>
{% include 'inc/image_attachments_panel.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

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

View File

@ -210,22 +210,7 @@
</div>
</div>
{% endif %}
<div class="card">
<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>
{% include 'inc/image_attachments_panel.html' %}
<div class="card">
<h5 class="card-header">
Reservations

View File

@ -30,7 +30,7 @@
{% if page %}
<div style="white-space: nowrap; overflow-x: scroll;">
{% 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 class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>

View File

@ -242,22 +242,7 @@
{% endif %}
</div>
</div>
<div class="card">
<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>
{% include 'inc/image_attachments_panel.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@ -4,10 +4,8 @@
{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}
{% 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 %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Add New Member</h5>
<div class="card-body">
@ -15,14 +13,10 @@
{% render_form membership_form %}
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-6 text-end">
<div class="text-end my-3">
<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="_save" class="btn btn-primary">Save</button>
</div>
</div>
</form>
{% 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

@ -25,11 +25,9 @@
Child Ranges {% badge object.get_child_ranges.count %}
</a>
</li>
{% if perms.ipam.view_ipaddress and object.status != 'container' %}
<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 %}">
IP Addresses {% badge object.get_child_ips.count %}
</a>
</li>
{% endif %}
{% endblock %}

View File

@ -24,7 +24,7 @@
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<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>
</div>
</div>

View File

@ -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('&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):
"""
Render edit, delete, and changelog buttons for an object.

View File

@ -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

View File

@ -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

View File

@ -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)..."