diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 17533e2c9..594f23f9a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1c31f0c29..b1193ae02 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.4 + placeholder: v3.1.5 validations: required: true - type: dropdown diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index acf13b82f..742e93804 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational. !!! 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. +## 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 .) + +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 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. diff --git a/docs/index.md b/docs/index.md index 02e523825..81c899387 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | Application | Django/Python | | Database | PostgreSQL 10+ | | Task queuing | Redis/django-rq | -| Live device access | NAPALM | +| Live device access | NAPALM (optional) | ## Supported Python Versions @@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments. ## 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. diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 7caa1e3ab..13c129398 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1,6 +1,14 @@ # 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) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 29213a4c5..898d77437 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,6 +1,42 @@ # 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 --- diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index c99bec8bc..69fe3cf1f 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """ {% 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 # + class ProviderTable(BaseTable): pk = ToggleColumn() name = tables.Column( @@ -118,6 +139,7 @@ class CircuitTable(BaseTable): template_code=CIRCUITTERMINATION_LINK, verbose_name='Side Z' ) + commit_rate = CommitRateColumn() comments = MarkdownColumn() tags = TagColumn( url_name='circuits:circuit_list' diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 368ee1336..cc46f5c6c 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' TYPE_FLEXSTACK = 'cisco-flexstack' 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_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' @@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), (TYPE_FLEXSTACK, 'Cisco FlexStack'), (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_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index c231f56df..f84b42aa1 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -678,7 +678,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( @@ -703,6 +703,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, @@ -716,15 +726,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) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a85fc7438..b3cff1a26 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2379,8 +2379,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, diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index bd28a30e7..0a2299945 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -124,5 +124,5 @@ class CustomFieldModelFilterForm(FilterForm): ) for cf in custom_fields: 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) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index ca2c6b900..9aac8454b 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -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, ClusterType @@ -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'}), } @@ -77,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): model = ExportTemplate fields = '__all__' fieldsets = ( - ('Custom Link', ('name', 'content_type', 'description')), + ('Export Template', ('name', 'content_type', 'description')), ('Template', ('template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) @@ -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'}), } diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 00c68939d..c0f040300 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -16,8 +16,8 @@ from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField, - StaticSelectMultiple, StaticSelect, add_blank_choice, + CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, ) from utilities.querysets import RestrictedQuerySet 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. - 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. 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() ) else: - field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField + field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField field = field_class( choices=choices, required=required, initial=initial, widget=StaticSelectMultiple() ) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index b128f7461..3c7ad3c15 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -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): """ diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 9191c1c5b..1a1fc13a8 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -810,6 +810,9 @@ class CustomFieldImportTest(TestCase): CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ '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: cf.save() @@ -820,19 +823,20 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'), - ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'), - ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''), + ('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', '"Choice A,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', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) self.assertEqual(response.status_code, 200) + self.assertEqual(Site.objects.count(), 3) # Validate data for 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['longtext'], 'Foo') 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['json'], {"foo": 123}) 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 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['longtext'], 'Bar') 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['json'], {"bar": 456}) 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 site3 = Site.objects.get(name='Site 3') diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index b19d4061b..ab88dfc1a 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -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, } diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 68eac5456..cef877245 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -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() @@ -592,6 +592,8 @@ class FHRPGroupForm(CustomFieldModelForm): return instance def clean(self): + super().clean() + ip_vrf = self.cleaned_data.get('ip_vrf') ip_address = self.cleaned_data.get('ip_address') ip_status = self.cleaned_data.get('ip_status') @@ -628,8 +630,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(), diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 1e7a72389..80088eb73 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -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 diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 23d6eb2a7..39706e9cc 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index f5e315801..b6df5e3c2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -9,6 +9,7 @@ from django.db.models import ProtectedError from django.forms.widgets import HiddenInput 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 @@ -623,10 +624,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): obj = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) + # 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, { - '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), }) @@ -664,9 +676,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), }) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index fe89a1b48..752715e7c 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index f8e59ef99..341369adf 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 9b46d50c8..61bcedf9c 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index d78429bf9..a54b6c324 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -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; } @@ -592,6 +597,10 @@ span.color-label { box-shadow: $box-shadow-sm; } +.badge a { + color: inherit; +} + .btn { white-space: nowrap; } @@ -898,6 +907,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 +929,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. diff --git a/netbox/project-static/styles/sidenav.scss b/netbox/project-static/styles/sidenav.scss index 9dfdd855a..4261e5120 100644 --- a/netbox/project-static/styles/sidenav.scss +++ b/netbox/project-static/styles/sidenav.scss @@ -223,11 +223,6 @@ font-weight: $font-weight-bold; color: var(--nbx-sidenav-parent-color); - &.active { - color: $accordion-button-active-color; - background: $accordion-button-active-bg; - } - &:after { display: inline-block; margin-left: auto; @@ -284,7 +279,7 @@ font-size: $font-size-sm; color: var(--nbx-sidenav-link-color); white-space: nowrap; - transition: $transition-100ms-ease-in-out; + transition-duration: 0ms; &.active { background-color: var(--nbx-sidebar-link-active-bg); diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss index c787d38bf..2db29ad38 100644 --- a/netbox/project-static/styles/theme-dark.scss +++ b/netbox/project-static/styles/theme-dark.scss @@ -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-bg: $component-active-bg; -$navbar-light-color: $gray-500; +$navbar-light-color: $darkest; $navbar-light-toggler-icon-bg: url("data:image/svg+xml,"); $navbar-light-toggler-border-color: $gray-700; diff --git a/netbox/project-static/styles/theme-light.scss b/netbox/project-static/styles/theme-light.scss index 05ead54b5..d417e1bf6 100644 --- a/netbox/project-static/styles/theme-light.scss +++ b/netbox/project-static/styles/theme-light.scss @@ -2,8 +2,6 @@ @import './theme-base.scss'; -$input-border-color: $gray-200; - // Theme colors (BS5 classes) $primary: #337ab7; $secondary: $gray-600; @@ -43,6 +41,8 @@ $theme-colors: ( $light: $gray-200; +$navbar-light-color: $gray-100; + $card-cap-color: $gray-800; $accordion-bg: transparent; diff --git a/netbox/project-static/styles/variables.scss b/netbox/project-static/styles/variables.scss index ddeb6025a..8075cf5b0 100644 --- a/netbox/project-static/styles/variables.scss +++ b/netbox/project-static/styles/variables.scss @@ -5,7 +5,7 @@ --nbx-sidebar-bg: #{$gray-200}; --nbx-sidebar-scroll: #{$gray-500}; --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-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25); --nbx-breadcrumb-bg: #{$light}; diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 7b1597bf0..cf3841dd2 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -20,7 +20,7 @@ {# Top bar #} -