Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2022-01-11 16:16:13 -05:00
commit c8713d94d8
73 changed files with 579 additions and 242 deletions

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.1.4 placeholder: v3.1.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.1.4 placeholder: v3.1.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
!!! info "IDE Integration" !!! info "IDE Integration"
Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment. Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
## Running Tests ## Running Tests
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.

View File

@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| Application | Django/Python | | Application | Django/Python |
| Database | PostgreSQL 10+ | | Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |
| Live device access | NAPALM | | Live device access | NAPALM (optional) |
## Supported Python Versions ## Supported Python Versions
@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments.
## Getting Started ## Getting Started
See the [installation guide](installation/index.md) for help getting NetBox up and running quickly. Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible.
Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox.

View File

@ -1,6 +1,14 @@
# Release Notes # Release Notes
Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page. NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows:
* **Major** - Introduces or removes an entire API or other core functionality
* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations
* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements
Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks.
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.1](./version-3.1.md) (December 2021) #### [Version 3.1](./version-3.1.md) (December 2021)

View File

@ -1,6 +1,42 @@
# NetBox v3.1 # NetBox v3.1
## v3.1.5 (FUTURE) ## v3.1.6 (FUTURE)
### Enhancements
* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table
* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats
* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types
* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables
### Bug Fixes
* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer
* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form
* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously
* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter
---
## 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
--- ---

View File

@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """
{% endif %} {% endif %}
""" """
#
# Table columns
#
class CommitRateColumn(tables.TemplateColumn):
"""
Humanize the commit rate in the column view
"""
template_code = """
{% load helpers %}
{{ record.commit_rate|humanize_speed }}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value) if value else None
# #
# Providers # Providers
# #
class ProviderTable(BaseTable): class ProviderTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.Column( name = tables.Column(
@ -118,6 +139,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z' verbose_name='Side Z'
) )
commit_rate = CommitRateColumn()
comments = MarkdownColumn() comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='circuits:circuit_list' url_name='circuits:circuit_list'

View File

@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack' TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus' TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
TYPE_STACKWISE80 = 'cisco-stackwise-80'
TYPE_STACKWISE160 = 'cisco-stackwise-160'
TYPE_STACKWISE320 = 'cisco-stackwise-320'
TYPE_STACKWISE480 = 'cisco-stackwise-480'
TYPE_JUNIPER_VCP = 'juniper-vcp' TYPE_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'), (TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), (TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
(TYPE_STACKWISE80, 'Cisco StackWise-80'),
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
(TYPE_JUNIPER_VCP, 'Juniper VCP'), (TYPE_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),

View File

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

View File

@ -2379,8 +2379,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
device_bay.installed_device = form.cleaned_data['installed_device'] device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save() device_bay.save()
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay)) 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', { return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay, 'device_bay': device_bay,

View File

@ -124,5 +124,5 @@ class CustomFieldModelFilterForm(FilterForm):
) )
for cf in custom_fields: for cf in custom_fields:
field_name = f'cf_{cf.name}' field_name = f'cf_{cf.name}'
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) self.fields[field_name] = cf.to_form_field(set_initial=False, enforce_required=False)
self.custom_field_filters.append(field_name) self.custom_field_filters.append(field_name)

View File

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

View File

@ -16,8 +16,8 @@ from extras.utils import FeatureQuery, extras_features
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from utilities import filters from utilities import filters
from utilities.forms import ( from utilities.forms import (
CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField, CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
StaticSelectMultiple, StaticSelect, add_blank_choice, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
) )
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex from utilities.validators import validate_regex
@ -283,7 +283,7 @@ class CustomField(ChangeLoggedModel):
""" """
Return a form field suitable for setting a CustomField's value for an object. Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
""" """
@ -332,7 +332,7 @@ class CustomField(ChangeLoggedModel):
choices=choices, required=required, initial=initial, widget=StaticSelect() choices=choices, required=required, initial=initial, widget=StaticSelect()
) )
else: else:
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class( field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple() choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
) )

View File

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

View File

@ -810,6 +810,9 @@ class CustomFieldImportTest(TestCase):
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C', 'Choice A', 'Choice B', 'Choice C',
]), ]),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
) )
for cf in custom_fields: for cf in custom_fields:
cf.save() cf.save()
@ -820,19 +823,20 @@ class CustomFieldImportTest(TestCase):
Import a Site in CSV format, including a value for each CustomField. Import a Site in CSV format, including a value for each CustomField.
""" """
data = ( data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'), ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'), ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'), ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''), ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
) )
csv_data = '\n'.join(','.join(row) for row in data) csv_data = '\n'.join(','.join(row) for row in data)
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(Site.objects.count(), 3)
# Validate data for site 1 # Validate data for site 1
site1 = Site.objects.get(name='Site 1') site1 = Site.objects.get(name='Site 1')
self.assertEqual(len(site1.custom_field_data), 8) self.assertEqual(len(site1.custom_field_data), 9)
self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123) self.assertEqual(site1.custom_field_data['integer'], 123)
@ -841,10 +845,11 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123}) self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A') self.assertEqual(site1.custom_field_data['select'], 'Choice A')
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
# Validate data for site 2 # Validate data for site 2
site2 = Site.objects.get(name='Site 2') site2 = Site.objects.get(name='Site 2')
self.assertEqual(len(site2.custom_field_data), 8) self.assertEqual(len(site2.custom_field_data), 9)
self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456) self.assertEqual(site2.custom_field_data['integer'], 456)
@ -853,6 +858,7 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456}) self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B') self.assertEqual(site2.custom_field_data['select'], 'Choice B')
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
# No custom field data should be set for site 3 # No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3') site3 = Site.objects.get(name='Site 3')

View File

@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, 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'], vrf=self.cleaned_data['ip_vrf'],
address=self.cleaned_data['ip_address'], address=self.cleaned_data['ip_address'],
status=self.cleaned_data['ip_status'], 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 assigned_object=instance
) )
ipaddress.save() ipaddress.save()
@ -592,6 +592,8 @@ class FHRPGroupForm(CustomFieldModelForm):
return instance return instance
def clean(self): def clean(self):
super().clean()
ip_vrf = self.cleaned_data.get('ip_vrf') ip_vrf = self.cleaned_data.get('ip_vrf')
ip_address = self.cleaned_data.get('ip_address') ip_address = self.cleaned_data.get('ip_address')
ip_status = self.cleaned_data.get('ip_status') ip_status = self.cleaned_data.get('ip_status')
@ -628,8 +630,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
class VLANGroupForm(CustomFieldModelForm): class VLANGroupForm(CustomFieldModelForm):
scope_type = ContentTypeChoiceField( scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False, required=False
widget=StaticSelect
) )
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),

View File

@ -1,5 +1,7 @@
import datetime import datetime
from django.test import override_settings
from django.urls import reverse
from netaddr import IPNetwork from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', '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): class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Role model = Role
@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', '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): class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange model = IPRange
@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', '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): class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPAddress model = IPAddress

View File

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

View File

@ -9,6 +9,7 @@ from django.db.models import ProtectedError
from django.forms.widgets import HiddenInput from django.forms.widgets import HiddenInput
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape from django.utils.html import escape
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -623,10 +624,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
obj = self.get_object(**kwargs) obj = self.get_object(**kwargs)
form = ConfirmationForm(initial=request.GET) form = ConfirmationForm(initial=request.GET)
return render(request, self.template_name, { # If this is an HTMX request, return only the rendered deletion form as modal content
'obj': obj, 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, 'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj), 'return_url': self.get_return_url(request, obj),
}) })
@ -664,9 +676,9 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
logger.debug("Form validation failed") logger.debug("Form validation failed")
return render(request, self.template_name, { return render(request, self.template_name, {
'obj': obj, 'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form, 'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj), '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 // Don't overtake dropdowns
z-index: 999; z-index: 999;
justify-content: center; justify-content: center;
background-color: var(--nbx-body-bg); background-color: $navbar-light-color;
.search-container { .search-container {
display: flex; display: flex;
@ -452,8 +452,8 @@ main.login-container {
} }
.footer { .footer {
background-color: $tab-content-bg;
padding: 0; padding: 0;
.nav-link { .nav-link {
padding: 0.5rem; padding: 0.5rem;
} }
@ -517,6 +517,10 @@ h6.accordion-item-title {
} }
} }
.navbar {
border-bottom: 1px solid $border-color;
}
.navbar-brand { .navbar-brand {
padding-top: 0.75rem; padding-top: 0.75rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
@ -554,6 +558,7 @@ div.content-container {
} }
div.content { div.content {
background-color: $tab-content-bg;
flex: 1; flex: 1;
} }
@ -592,6 +597,10 @@ span.color-label {
box-shadow: $box-shadow-sm; box-shadow: $box-shadow-sm;
} }
.badge a {
color: inherit;
}
.btn { .btn {
white-space: nowrap; white-space: nowrap;
} }
@ -898,6 +907,7 @@ div.card-overlay {
// Tabbed content // Tabbed content
.nav-tabs { .nav-tabs {
background-color: $body-bg;
.nav-link { .nav-link {
&:hover { &:hover {
// Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border. // Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border.
@ -919,14 +929,6 @@ div.card-overlay {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: $spacer; 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. // Override masonry-layout styles when printing.

View File

@ -223,11 +223,6 @@
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
color: var(--nbx-sidenav-parent-color); color: var(--nbx-sidenav-parent-color);
&.active {
color: $accordion-button-active-color;
background: $accordion-button-active-bg;
}
&:after { &:after {
display: inline-block; display: inline-block;
margin-left: auto; margin-left: auto;
@ -284,7 +279,7 @@
font-size: $font-size-sm; font-size: $font-size-sm;
color: var(--nbx-sidenav-link-color); color: var(--nbx-sidenav-link-color);
white-space: nowrap; white-space: nowrap;
transition: $transition-100ms-ease-in-out; transition-duration: 0ms;
&.active { &.active {
background-color: var(--nbx-sidebar-link-active-bg); background-color: var(--nbx-sidebar-link-active-bg);

View File

@ -146,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-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg; $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-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; $navbar-light-toggler-border-color: $gray-700;

View File

@ -2,8 +2,6 @@
@import './theme-base.scss'; @import './theme-base.scss';
$input-border-color: $gray-200;
// Theme colors (BS5 classes) // Theme colors (BS5 classes)
$primary: #337ab7; $primary: #337ab7;
$secondary: $gray-600; $secondary: $gray-600;
@ -43,6 +41,8 @@ $theme-colors: (
$light: $gray-200; $light: $gray-200;
$navbar-light-color: $gray-100;
$card-cap-color: $gray-800; $card-cap-color: $gray-800;
$accordion-bg: transparent; $accordion-bg: transparent;

View File

@ -5,7 +5,7 @@
--nbx-sidebar-bg: #{$gray-200}; --nbx-sidebar-bg: #{$gray-200};
--nbx-sidebar-scroll: #{$gray-500}; --nbx-sidebar-scroll: #{$gray-500};
--nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)}; --nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)};
--nbx-sidebar-link-active-bg: #{$blue-100}; --nbx-sidebar-link-active-bg: #9cc8f8;
--nbx-sidebar-title-color: #{$text-muted}; --nbx-sidebar-title-color: #{$text-muted};
--nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25); --nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25);
--nbx-breadcrumb-bg: #{$light}; --nbx-breadcrumb-bg: #{$light};

View File

@ -20,7 +20,7 @@
</div> </div>
{# Top bar #} {# 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 #} {# Mobile Navigation #}
<div class="nav-mobile"> <div class="nav-mobile">
@ -103,6 +103,9 @@
</div> </div>
{% endif %} {% endif %}
{# BS5 pop-up modals #}
{% block modals %}{% endblock %}
{# Page footer #} {# Page footer #}
<footer class="footer container-fluid"> <footer class="footer container-fluid">
<div class="row align-items-center justify-content-between mx-0"> <div class="row align-items-center justify-content-between mx-0">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@
{% block breadcrumbs %} {% 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' %}">Reports</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</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 %} {% block subtitle %}
{% if report.description %} {% if report.description %}
@ -18,14 +18,22 @@
<div class="text-muted">{{ report.description|render_markdown }}</div> <div class="text-muted">{{ report.description|render_markdown }}</div>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock subtitle %}
{% block controls %}{% endblock %} {% 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 %} {% 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"> <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %} {% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary"> <button type="submit" name="_run" class="btn btn-primary">
@ -38,7 +46,7 @@
</form> </form>
</div> </div>
{% endif %} {% endif %}
<div class="row px-3"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% if report.result %} {% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}"> Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
@ -47,4 +55,5 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endblock %} </div>
{% endblock content %}

View File

@ -1,7 +1,7 @@
{% extends 'extras/report.html' %} {% extends 'extras/report.html' %}
{% block content-wrapper %} {% 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 %}> <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' %} {% include 'extras/htmx/report_result.html' %}
</div> </div>

View File

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

View File

@ -100,4 +100,8 @@
<div class="tab-content"> <div class="tab-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </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 %} {% load form_helpers %}
{% block title %}Delete {{ obj_type }}?{% endblock %} {% block title %}Delete {{ object_type }}?{% endblock %}
{% block message %} {% block header %}{% endblock %}
<p>Are you sure you want to <strong class="text-danger">delete</strong> {{ obj_type }} <strong>{{ obj }}</strong>?</p>
{% block message_extra %}{% endblock %} {% block content %}
{% endblock message %} <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 %} {% endif %}
</div> </div>
{# Table config form #}
{% table_config_form table table_name="ObjectTable" %}
{% endblock content-wrapper %} {% 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

@ -38,7 +38,7 @@
</div> </div>
{% else %} {% else %}
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}"> <a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}?next={{ request.path }}">
<i class="mdi mdi-login-variant"></i> Log In <i class="mdi mdi-login-variant"></i> Log In
</a> </a>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"> <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">

View File

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

View File

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

View File

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

View File

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

View File

@ -37,5 +37,9 @@
</div> </div>
</div> </div>
</form> </form>
{% table_config_form table %}
{% endblock %} {% endblock %}
{% block modals %}
{{ block.super }}
{% 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 %} {% endif %}
</ul> </ul>
{% endblock %} {% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% block content %}{% endblock %}
</div>
{% endblock %}

View File

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

View File

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

View File

@ -95,6 +95,10 @@
<h2><a href="{% url 'virtualization:cluster_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cluster_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cluster_count }}</a></h2> <h2><a href="{% url 'virtualization:cluster_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cluster_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cluster_count }}</a></h2>
<p>Clusters</p> <p>Clusters</p>
</div> </div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
<p>Cables</p>
</div>
</div> </div>
</div> </div>
{% plugin_right_page object %} {% plugin_right_page object %}

View File

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

View File

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

View File

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

View File

@ -1,13 +1,13 @@
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits.models import Circuit from circuits.models import Circuit
from dcim.models import Device, Rack, Site from dcim.models import Device, Rack, Site, Cable
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from ipam.models import IPAddress, Prefix, VLAN, VRF from ipam.models import IPAddress, Prefix, VLAN, VRF
from tenancy import filtersets from tenancy import filtersets
from tenancy.models import * from tenancy.models import *
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine, Cluster
from . import serializers from . import serializers
@ -47,7 +47,8 @@ class TenantViewSet(CustomFieldModelViewSet):
site_count=count_related(Site, 'tenant'), site_count=count_related(Site, 'tenant'),
virtualmachine_count=count_related(VirtualMachine, 'tenant'), virtualmachine_count=count_related(VirtualMachine, 'tenant'),
vlan_count=count_related(VLAN, 'tenant'), vlan_count=count_related(VLAN, 'tenant'),
vrf_count=count_related(VRF, 'tenant') vrf_count=count_related(VRF, 'tenant'),
cluster_count=count_related(Cluster, 'tenant')
) )
serializer_class = serializers.TenantSerializer serializer_class = serializers.TenantSerializer
filterset_class = filtersets.TenantFilterSet filterset_class = filtersets.TenantFilterSet

View File

@ -3,7 +3,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from circuits.models import Circuit from circuits.models import Circuit
from dcim.models import Site, Rack, Device, RackReservation from dcim.models import Site, Rack, Device, RackReservation, Cable
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from netbox.views import generic from netbox.views import generic
from utilities.tables import configure_table from utilities.tables import configure_table
@ -112,6 +112,7 @@ class TenantView(generic.ObjectView):
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
} }
return { return {

View File

@ -31,6 +31,7 @@ __all__ = (
'CSVDataField', 'CSVDataField',
'CSVFileField', 'CSVFileField',
'CSVModelChoiceField', 'CSVModelChoiceField',
'CSVMultipleChoiceField',
'CSVMultipleContentTypeField', 'CSVMultipleContentTypeField',
'CSVTypedChoiceField', 'CSVTypedChoiceField',
'DynamicModelChoiceField', 'DynamicModelChoiceField',
@ -168,11 +169,11 @@ class ContentTypeChoiceMixin:
class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
pass widget = widgets.StaticSelect
class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
pass widget = widgets.StaticSelectMultiple
# #
@ -263,10 +264,7 @@ class CSVFileField(forms.FileField):
return value return value
class CSVChoiceField(forms.ChoiceField): class CSVChoicesMixin:
"""
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
"""
STATIC_CHOICES = True STATIC_CHOICES = True
def __init__(self, *, choices=(), **kwargs): def __init__(self, *, choices=(), **kwargs):
@ -274,6 +272,25 @@ class CSVChoiceField(forms.ChoiceField):
self.choices = unpack_grouped_choices(choices) self.choices = unpack_grouped_choices(choices)
class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
"""
A CSV field which accepts a single selection value.
"""
pass
class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
"""
A CSV field which accepts multiple selection values.
"""
def to_python(self, value):
if not value:
return []
if not isinstance(value, str):
raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
return value.split(',')
class CSVTypedChoiceField(forms.TypedChoiceField): class CSVTypedChoiceField(forms.TypedChoiceField):
STATIC_CHOICES = True STATIC_CHOICES = True

View File

@ -14,7 +14,6 @@ __all__ = (
'BulkEditNullBooleanSelect', 'BulkEditNullBooleanSelect',
'ClearableFileInput', 'ClearableFileInput',
'ColorSelect', 'ColorSelect',
'ContentTypeSelect',
'DatePicker', 'DatePicker',
'DateTimePicker', 'DateTimePicker',
'NumericArrayField', 'NumericArrayField',
@ -110,15 +109,6 @@ class SelectWithPK(StaticSelect):
option_template_name = 'widgets/select_option_with_pk.html' 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): class SelectSpeedWidget(forms.NumberInput):
""" """
Speed field with dropdown selections for convenience. Speed field with dropdown selections for convenience.

View File

@ -130,7 +130,7 @@ class ActionsColumn(tables.Column):
def render(self, record, table, **kwargs): def render(self, record, table, **kwargs):
# Skip dummy records (e.g. available VLANs) or those with no actions # Skip dummy records (e.g. available VLANs) or those with no actions
if not hasattr(record, 'pk') or not self.actions: if not getattr(record, 'pk', None) or not self.actions:
return '' return ''
model = table.Meta.model model = table.Meta.model
@ -239,7 +239,7 @@ class ColoredLabelColumn(tables.TemplateColumn):
{% load helpers %} {% load helpers %}
{% if value %} {% if value %}
<span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}"> <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
{{ value }} <a href="{{ value.get_absolute_url }}">{{ value }}</a>
</span> </span>
{% else %} {% else %}
&mdash; &mdash;

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 <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>&nbsp;Delete
</a> </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

@ -20,11 +20,13 @@ class EnhancedURLValidator(URLValidator):
r'(?::\d{2,5})?' # Port number r'(?::\d{2,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE) r'\Z', re.IGNORECASE)
schemes = None
def __init__(self, schemes=None, **kwargs): def __call__(self, value):
super().__init__(**kwargs) if self.schemes is None:
if schemes is not None: # We can't load the allowed schemes until the configuration has been initialized
self.schemes = get_config().ALLOWED_URL_SCHEMES self.schemes = get_config().ALLOWED_URL_SCHEMES
return super().__call__(value)
class ExclusionValidator(BaseValidator): class ExclusionValidator(BaseValidator):

View File

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

View File

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

View File

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