Merge pull request #8257 from netbox-community/develop

Release v3.1.5
This commit is contained in:
Jeremy Stretch 2022-01-06 09:52:54 -05:00 committed by GitHub
commit d3e2241ff7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 467 additions and 308 deletions

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.4
placeholder: v3.1.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.1.4
placeholder: v3.1.5
validations:
required: true
- type: dropdown

View File

@ -1,5 +1,23 @@
# NetBox v3.1
## v3.1.5 (2022-01-06)
### Enhancements
* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion
* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form
* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table
### Bug Fixes
* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view
* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other"
* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay
* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection
* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links
---
## v3.1.4 (2022-01-03)
### Enhancements

View File

@ -578,7 +578,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
field_groups = [
['q', 'tag'],
['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'],
['type', 'status', 'color', 'length', 'length_unit'],
['tenant_group_id', 'tenant_id'],
]
region_id = DynamicModelMultipleChoiceField(
@ -603,6 +603,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'site_id': '$site_id'
}
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device')
)
type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices),
required=False,
@ -616,15 +626,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
color = ColorField(
required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device')
length = forms.IntegerField(
required=False
)
length_unit = forms.ChoiceField(
choices=add_blank_choice(CableLengthUnitChoices),
required=False
)
tag = TagFilterField(model)

View File

@ -2035,8 +2035,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save()
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
return_url = self.get_return_url(request)
return redirect('dcim:device', pk=device_bay.device.pk)
return redirect(return_url)
return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay,

View File

@ -7,8 +7,8 @@ from extras.models import *
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup
@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
}
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@ -57,6 +61,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
('Templates', ('link_text', 'link_url')),
)
widgets = {
'button_class': StaticSelect(),
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Webhook', ('name', 'content_types', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
('Conditions', ('conditions',)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
labels = {
'type_create': 'Creations',
'type_update': 'Updates',
'type_delete': 'Deletions',
}
widgets = {
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
}

View File

@ -21,7 +21,7 @@ from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
# Set field choices, adding a blank choice to avoid forced selections
self.field_attrs['choices'] = add_blank_choice(choices)
class MultiChoiceVar(ChoiceVar):
class MultiChoiceVar(ScriptVariable):
"""
Like ChoiceVar, but allows for the selection of multiple choices.
"""
form_field = forms.MultipleChoiceField
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
class ObjectVar(ScriptVariable):
"""

View File

@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP,
FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP,
}

View File

@ -580,7 +580,7 @@ class FHRPGroupForm(CustomFieldModelForm):
vrf=self.cleaned_data['ip_vrf'],
address=self.cleaned_data['ip_address'],
status=self.cleaned_data['ip_status'],
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
assigned_object=instance
)
ipaddress.save()
@ -628,8 +628,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
class VLANGroupForm(CustomFieldModelForm):
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
widget=StaticSelect
required=False
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),

View File

@ -1,5 +1,7 @@
import datetime
from django.test import override_settings
from django.urls import reverse
from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_aggregate_prefixes(self):
rir = RIR.objects.first()
aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir)
prefixes = (
Prefix(prefix=IPNetwork('192.168.1.0/24')),
Prefix(prefix=IPNetwork('192.168.2.0/24')),
Prefix(prefix=IPNetwork('192.168.3.0/24')),
)
Prefix.objects.bulk_create(prefixes)
self.assertEqual(aggregate.get_child_prefixes().count(), 3)
url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
self.assertHttpStatus(self.client.get(url), 200)
class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Role
@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_prefixes(self):
prefixes = (
Prefix(prefix=IPNetwork('192.168.0.0/16')),
Prefix(prefix=IPNetwork('192.168.1.0/24')),
Prefix(prefix=IPNetwork('192.168.2.0/24')),
Prefix(prefix=IPNetwork('192.168.3.0/24')),
)
Prefix.objects.bulk_create(prefixes)
self.assertEqual(prefixes[0].get_child_prefixes().count(), 3)
url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_ipranges(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_ranges = (
IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
)
IPRange.objects.bulk_create(ip_ranges)
self.assertEqual(prefix.get_child_ranges().count(), 3)
url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_ipaddresses(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.1/16')),
IPAddress(address=IPNetwork('192.168.0.2/16')),
IPAddress(address=IPNetwork('192.168.0.3/16')),
)
IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(prefix.get_child_ips().count(), 3)
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange
@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_iprange_ipaddresses(self):
iprange = IPRange.objects.create(
start_address=IPNetwork('192.168.0.1/24'),
end_address=IPNetwork('192.168.0.100/24'),
size=99
)
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.1/24')),
IPAddress(address=IPNetwork('192.168.0.2/24')),
IPAddress(address=IPNetwork('192.168.0.3/24')),
)
IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(iprange.get_child_ips().count(), 3)
url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
self.assertHttpStatus(self.client.get(url), 200)
class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPAddress

View File

@ -505,9 +505,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant',
)
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
def prep_table_data(self, request, queryset, parent):
show_available = bool(request.GET.get('show_available', 'true') == 'true')
@ -531,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView):
class PrefixDeleteView(generic.ObjectDeleteView):
queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html'
class PrefixBulkImportView(generic.BulkImportView):

View File

@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup
#
VERSION = '3.1.4'
VERSION = '3.1.5'
# Hostname
HOSTNAME = platform.node()

View File

@ -10,6 +10,7 @@ from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
@ -430,10 +431,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
obj = self.get_object(kwargs)
form = ConfirmationForm(initial=request.GET)
return render(request, self.template_name, {
'obj': obj,
# If this is an HTMX request, return only the rendered deletion form as modal content
if is_htmx(request):
viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete'
form_url = reverse(viewname, kwargs={'pk': obj.pk})
return render(request, 'htmx/delete_form.html', {
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'form_url': form_url,
})
return render(request, self.template_name, {
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj),
})
@ -466,9 +478,9 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
logger.debug("Form validation failed")
return render(request, self.template_name, {
'obj': obj,
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj),
})

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -358,7 +358,7 @@ nav.search {
// Don't overtake dropdowns
z-index: 999;
justify-content: center;
background-color: var(--nbx-body-bg);
background-color: $navbar-light-color;
.search-container {
display: flex;
@ -452,8 +452,8 @@ main.login-container {
}
.footer {
background-color: $tab-content-bg;
padding: 0;
.nav-link {
padding: 0.5rem;
}
@ -517,6 +517,10 @@ h6.accordion-item-title {
}
}
.navbar {
border-bottom: 1px solid $border-color;
}
.navbar-brand {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
@ -554,6 +558,7 @@ div.content-container {
}
div.content {
background-color: $tab-content-bg;
flex: 1;
}
@ -898,6 +903,7 @@ div.card-overlay {
// Tabbed content
.nav-tabs {
background-color: $body-bg;
.nav-link {
&:hover {
// Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border.
@ -919,14 +925,6 @@ div.card-overlay {
display: flex;
flex-direction: column;
padding: $spacer;
background-color: $tab-content-bg;
border-bottom: 1px solid $nav-tabs-border-color;
// Remove background and border when printing.
@media print {
background-color: var(--nbx-body-bg) !important;
border-bottom: none !important;
}
}
// Override masonry-layout styles when printing.

View File

@ -33,95 +33,6 @@ $darkest: #171b1d;
@import '../node_modules/bootstrap/scss/variables';
// Make color palette colors available as theme colors.
// For example, you could use `.bg-red-100`, if needed.
$theme-color-addons: (
'darker': $darker,
'darkest': $darkest,
'gray': $gray-400,
'gray-100': $gray-100,
'gray-200': $gray-200,
'gray-300': $gray-300,
'gray-400': $gray-400,
'gray-500': $gray-500,
'gray-600': $gray-600,
'gray-700': $gray-700,
'gray-800': $gray-800,
'gray-900': $gray-900,
'red-100': $red-100,
'red-200': $red-200,
'red-300': $red-300,
'red-400': $red-400,
'red-500': $red-500,
'red-600': $red-600,
'red-700': $red-700,
'red-800': $red-800,
'red-900': $red-900,
'yellow-100': $yellow-100,
'yellow-200': $yellow-200,
'yellow-300': $yellow-300,
'yellow-400': $yellow-400,
'yellow-500': $yellow-500,
'yellow-600': $yellow-600,
'yellow-700': $yellow-700,
'yellow-800': $yellow-800,
'yellow-900': $yellow-900,
'green-100': $green-100,
'green-200': $green-200,
'green-300': $green-300,
'green-400': $green-400,
'green-500': $green-500,
'green-600': $green-600,
'green-700': $green-700,
'green-800': $green-800,
'green-900': $green-900,
'blue-100': $blue-100,
'blue-200': $blue-200,
'blue-300': $blue-300,
'blue-400': $blue-400,
'blue-500': $blue-500,
'blue-600': $blue-600,
'blue-700': $blue-700,
'blue-800': $blue-800,
'blue-900': $blue-900,
'cyan-100': $cyan-100,
'cyan-200': $cyan-200,
'cyan-300': $cyan-300,
'cyan-400': $cyan-400,
'cyan-500': $cyan-500,
'cyan-600': $cyan-600,
'cyan-700': $cyan-700,
'cyan-800': $cyan-800,
'cyan-900': $cyan-900,
'indigo-100': $indigo-100,
'indigo-200': $indigo-200,
'indigo-300': $indigo-300,
'indigo-400': $indigo-400,
'indigo-500': $indigo-500,
'indigo-600': $indigo-600,
'indigo-700': $indigo-700,
'indigo-800': $indigo-800,
'indigo-900': $indigo-900,
'purple-100': $purple-100,
'purple-200': $purple-200,
'purple-300': $purple-300,
'purple-400': $purple-400,
'purple-500': $purple-500,
'purple-600': $purple-600,
'purple-700': $purple-700,
'purple-800': $purple-800,
'purple-900': $purple-900,
'pink-100': $pink-100,
'pink-200': $pink-200,
'pink-300': $pink-300,
'pink-400': $pink-400,
'pink-500': $pink-500,
'pink-600': $pink-600,
'pink-700': $pink-700,
'pink-800': $pink-800,
'pink-900': $pink-900,
);
// This is the same value as the default from Bootstrap, but it needs to be in scope prior to
// importing _variables.scss from Bootstrap.
$btn-close-width: 1em;

View File

@ -3,6 +3,7 @@
@use 'sass:map';
@import './theme-base';
// Theme colors (BS5 classes)
$primary: $blue-300;
$secondary: $gray-500;
$success: $green-300;
@ -13,6 +14,7 @@ $light: $gray-300;
$dark: $gray-500;
$theme-colors: (
// BS5 theme colors
'primary': $primary,
'secondary': $secondary,
'success': $success,
@ -21,18 +23,23 @@ $theme-colors: (
'danger': $danger,
'light': $light,
'dark': $dark,
'red': $red-300,
'yellow': $yellow-300,
'green': $green-300,
// General-purpose palette
'blue': $blue-300,
'cyan': $cyan-300,
'indigo': $indigo-300,
'purple': $purple-300,
'pink': $pink-300,
'red': $red-300,
'orange': $orange-300,
'yellow': $yellow-300,
'green': $green-300,
'teal': $teal-300,
'cyan': $cyan-300,
'gray': $gray-300,
'black': $black,
'white': $white,
);
$theme-colors: map-merge($theme-colors, $theme-color-addons);
// Gradient
$gradient: linear-gradient(180deg, rgba($white, 0.15), rgba($white, 0));
@ -139,7 +146,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
$nav-pills-link-active-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg;
$navbar-light-color: $gray-500;
$navbar-light-color: $darkest;
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
$navbar-light-toggler-border-color: $gray-700;

View File

@ -2,28 +2,47 @@
@import './theme-base.scss';
$input-border-color: $gray-200;
// Theme colors (BS5 classes)
$primary: #337ab7;
$secondary: $gray-600;
$success: $green-500;
$info: #54d6f0;
$warning: $yellow-500;
$danger: $red-500;
$light: $gray-200;
$dark: $gray-800;
$theme-colors: map-merge(
$theme-colors,
(
'primary': #337ab7,
'info': #54d6f0,
'red': $red-500,
'yellow': $yellow-500,
'green': $green-500,
$theme-colors: (
// BS5 theme colors
'primary': $primary,
'secondary': $secondary,
'success': $success,
'info': $info,
'warning': $warning,
'danger': $danger,
'light': $light,
'dark': $dark,
// General-purpose palette
'blue': $blue-500,
'cyan': $cyan-500,
'indigo': $indigo-500,
'purple': $purple-500,
'pink': $pink-500,
)
'red': $red-500,
'orange': $orange-500,
'yellow': $yellow-500,
'green': $green-500,
'teal': $teal-500,
'cyan': $cyan-500,
'gray': $gray-500,
'black': $black,
'white': $white,
);
$theme-colors: map-merge($theme-colors, $theme-color-addons);
$light: $gray-200;
$navbar-light-color: $gray-100;
$card-cap-color: $gray-800;
$accordion-bg: transparent;

View File

@ -20,7 +20,7 @@
</div>
{# Top bar #}
<nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid border-bottom noprint">
<nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid noprint">
{# Mobile Navigation #}
<div class="nav-mobile">
@ -103,6 +103,9 @@
</div>
{% endif %}
{# BS5 pop-up modals #}
{% block modals %}{% endblock %}
{# Page footer #}
<footer class="footer container-fluid">
<div class="row align-items-center justify-content-between mx-0">

View File

@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -39,5 +39,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -77,5 +77,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -39,5 +39,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -42,5 +42,8 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -4,7 +4,7 @@
{% render_errors form %}
{% block content %}
<form action="." method="post">
<form action="" method="post">
{% csrf_token %}
<div class="row mb-3">
<div class="col col-md-6 offset-md-3">

View File

@ -73,3 +73,5 @@
</div>
{% endblock content-wrapper %}
{% block modals %}{% endblock %}

View File

@ -10,7 +10,7 @@
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
{% endblock %}
{% endblock breadcrumbs %}
{% block subtitle %}
{% if report.description %}
@ -18,14 +18,22 @@
<div class="text-muted">{{ report.description|render_markdown }}</div>
</div>
{% endif %}
{% endblock %}
{% endblock subtitle %}
{% block controls %}{% endblock %}
{% block tabs %}{% endblock %}
{% block content-wrapper %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#report" role="tab" data-bs-toggle="tab" class="nav-link active">Report</a>
</li>
</ul>
{% endblock tabs %}
{% block content %}
<div role="tabpanel" class="tab-pane active" id="report">
{% if perms.extras.run_report %}
<div class="px-3 float-end noprint">
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary">
@ -38,7 +46,7 @@
</form>
</div>
{% endif %}
<div class="row px-3">
<div class="row">
<div class="col col-md-12">
{% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
@ -47,4 +55,5 @@
{% endif %}
</div>
</div>
{% endblock %}
</div>
{% endblock content %}

View File

@ -1,7 +1,7 @@
{% extends 'extras/report.html' %}
{% block content-wrapper %}
<div class="row px-3">
<div class="row p-3">
<div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:report_result' job_result_pk=result.pk %}" hx-trigger="every 3s"{% endif %}>
{% include 'extras/htmx/report_result.html' %}
</div>

View File

@ -7,18 +7,18 @@
{% block object_identifier %}
{{ script.full_name }}
{% endblock %}
{% endblock object_identifier %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
{% endblock %}
{% endblock breadcrumbs %}
{% block subtitle %}
<div class="object-subtitle">
<div class="text-muted">{{ script.Meta.description|render_markdown }}</div>
</div>
{% endblock %}
{% endblock subtitle %}
{% block controls %}{% endblock %}
@ -31,10 +31,9 @@
<a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
</li>
</ul>
{% endblock %}
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{% block content %}
<div role="tabpanel" class="tab-pane active" id="run">
<div class="row">
<div class="col">
@ -71,5 +70,4 @@
<code class="h6 my-3 d-block">{{ script.filename }}</code>
<pre class="block">{{ script.source }}</pre>
</div>
</div>
{% endblock content-wrapper %}
{% endblock content %}

View File

@ -100,4 +100,8 @@
<div class="tab-content">
{% block content %}{% endblock %}
</div>
{% endblock %}
{% endblock content-wrapper %}
{% block modals %}
{% include 'inc/htmx_modal.html' %}
{% endblock modals %}

View File

@ -1,9 +1,16 @@
{% extends 'generic/confirmation_form.html' %}
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Delete {{ obj_type }}?{% endblock %}
{% block title %}Delete {{ object_type }}?{% endblock %}
{% block message %}
<p>Are you sure you want to <strong class="text-danger">delete</strong> {{ obj_type }} <strong>{{ obj }}</strong>?</p>
{% block message_extra %}{% endblock %}
{% endblock message %}
{% block header %}{% endblock %}
{% block content %}
<div class="modal" tabindex="-1" style="display: block; position: static">
<div class="modal-dialog">
<div class="modal-content" >
{% include 'htmx/delete_form.html' %}
</div>
</div>
</div>
{% endblock %}

View File

@ -133,6 +133,8 @@
{% endif %}
</div>
{# Table config form #}
{% table_config_form table table_name="ObjectTable" %}
{% endblock content-wrapper %}
{% block modals %}
{% table_config_form table table_name="ObjectTable" %}
{% endblock modals %}

View File

@ -0,0 +1,20 @@
{% load form_helpers %}
<form action="{{ form_url }}" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Confirm Deletion</h5>
</div>
<div class="modal-body">
<p>Are you sure you want to <strong class="text-danger">delete</strong> {{ object_type }} <strong>{{ object }}</strong>?</p>
{% render_form form %}
</div>
<div class="modal-footer">
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-secondary">Cancel</a>
{% else %}
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
{% endif %}
<button type="submit" class="btn btn-danger">Delete</button>
</div>
</form>

View File

@ -0,0 +1,7 @@
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" id="htmx-modal-content">
{# Dynamic content goes here #}
</div>
</div>
</div>

View File

@ -37,5 +37,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -35,5 +35,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -35,5 +35,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -35,5 +35,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -37,5 +37,8 @@
</div>
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{% table_config_form table %}
{% endblock modals %}

View File

@ -1,5 +0,0 @@
{% extends 'generic/object_delete.html' %}
{% block message_extra %}
<p>Note: This will <strong>not</strong> delete any child prefixes or IP addresses.</p>
{% endblock %}

View File

@ -37,9 +37,3 @@
{% endif %}
</ul>
{% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% block content %}{% endblock %}
</div>
{% endblock %}

View File

@ -5,13 +5,14 @@
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@ -5,13 +5,14 @@
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@ -6,13 +6,11 @@
<form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.virtualization.change_cluster %}
@ -23,5 +21,8 @@
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@ -6,13 +6,11 @@
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.virtualization.change_virtualmachine %}
@ -28,5 +26,8 @@
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@ -37,5 +37,8 @@
<div class="clearfix"></div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% table_config_form table %}
{% endblock %}
{% endblock modals %}

View File

@ -168,11 +168,11 @@ class ContentTypeChoiceMixin:
class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
pass
widget = widgets.StaticSelect
class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
pass
widget = widgets.StaticSelectMultiple
#

View File

@ -14,7 +14,6 @@ __all__ = (
'BulkEditNullBooleanSelect',
'ClearableFileInput',
'ColorSelect',
'ContentTypeSelect',
'DatePicker',
'DateTimePicker',
'NumericArrayField',
@ -110,15 +109,6 @@ class SelectWithPK(StaticSelect):
option_template_name = 'widgets/select_option_with_pk.html'
class ContentTypeSelect(StaticSelect):
"""
Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
<option value="37" api-value="console-server-port">console server port</option>
This attribute can be used to reference the relevant API endpoint for a particular ContentType.
"""
option_template_name = 'widgets/select_contenttype.html'
class SelectSpeedWidget(forms.NumberInput):
"""
Speed field with dropdown selections for convenience.

View File

@ -1,3 +1,9 @@
<a href="{{ url }}" class="btn btn-sm btn-danger" role="button">
<a href="#"
hx-get="{{ url }}"
hx-target="#htmx-modal-content"
class="btn btn-sm btn-danger"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
>
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>&nbsp;Delete
</a>

View File

@ -1 +0,0 @@
<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}{% if widget.value %} api-value="{{ widget.label|slugify }}"{% endif %}>{{ widget.label.label|default:widget.label|capfirst }}</option>

View File

@ -80,6 +80,12 @@ class ClusterTable(BaseTable):
name = tables.Column(
linkify=True
)
type = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
tenant = tables.Column(
linkify=True
)

View File

@ -3,7 +3,7 @@ from django import forms
from dcim.choices import LinkStatusChoices
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from ipam.models import VLAN
from utilities.forms import DynamicModelChoiceField
from utilities.forms import add_blank_choice, DynamicModelChoiceField
from wireless.choices import *
from wireless.constants import SSID_MAX_LENGTH
from wireless.models import *
@ -45,24 +45,27 @@ class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
label='VLAN'
)
ssid = forms.CharField(
max_length=SSID_MAX_LENGTH,
required=False
required=False,
label='SSID'
)
description = forms.CharField(
required=False
)
auth_type = forms.ChoiceField(
choices=WirelessAuthTypeChoices,
choices=add_blank_choice(WirelessAuthTypeChoices),
required=False
)
auth_cipher = forms.ChoiceField(
choices=WirelessAuthCipherChoices,
choices=add_blank_choice(WirelessAuthCipherChoices),
required=False
)
auth_psk = forms.CharField(
required=False
required=False,
label='Pre-shared key'
)
class Meta:
@ -76,25 +79,27 @@ class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
)
ssid = forms.CharField(
max_length=SSID_MAX_LENGTH,
required=False
required=False,
label='SSID'
)
status = forms.ChoiceField(
choices=LinkStatusChoices,
choices=add_blank_choice(LinkStatusChoices),
required=False
)
description = forms.CharField(
required=False
)
auth_type = forms.ChoiceField(
choices=WirelessAuthTypeChoices,
choices=add_blank_choice(WirelessAuthTypeChoices),
required=False
)
auth_cipher = forms.ChoiceField(
choices=WirelessAuthCipherChoices,
choices=add_blank_choice(WirelessAuthCipherChoices),
required=False
)
auth_psk = forms.CharField(
required=False
required=False,
label='Pre-shared key'
)
class Meta:

View File

@ -1,4 +1,4 @@
Django==3.2.10
Django==3.2.11
django-cors-headers==3.10.1
django-debug-toolbar==3.2.4
django-filter==21.1
@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3
Markdown==3.3.6
markdown-include==0.6.0
mkdocs-material==8.1.3
mkdocs-material==8.1.4
netaddr==0.8.0
Pillow==8.4.0
psycopg2-binary==2.9.3