diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index f13168f3c..23d5b8182 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.2 + placeholder: v3.1.3 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 277c9724f..00b464515 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.2 + placeholder: v3.1.3 validations: required: true - type: dropdown diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 3b502cab2..7fd510841 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -55,3 +55,7 @@ The link will only appear when viewing a device with a manufacturer name of "Cis ## Link Groups Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group. + +## Table Columns + +Custom links can also be included in object tables by selecting the desired links from the table configuration form. When displayed, each link will render as a hyperlink for its corresponding object. When exported (e.g. as CSV data), each link render only its URL. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ce6a67327..d27c3f76f 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,5 +1,31 @@ # NetBox v3.1 +## v3.1.3 (2021-12-29) + +### Enhancements + +* [#6782](https://github.com/netbox-community/netbox/issues/6782) - Enable the inclusion of custom links in tables +* [#7600](https://github.com/netbox-community/netbox/issues/7600) - Include count of available IPs on prefix view +* [#8034](https://github.com/netbox-community/netbox/issues/8034) - Enable specifying custom field validators during CSV import +* [#8100](https://github.com/netbox-community/netbox/issues/8100) - Add "other" choice for FHRP group protocol +* [#8175](https://github.com/netbox-community/netbox/issues/8175) - Display parent object when attaching an image + +### Bug Fixes + +* [#7246](https://github.com/netbox-community/netbox/issues/7246) - Don't attempt to URL-decode NAPALM response payloads +* [#7290](https://github.com/netbox-community/netbox/issues/7290) - Defer loading API-backed form fields +* [#7887](https://github.com/netbox-community/netbox/issues/7887) - Forward `HTTP_X_FORWARDED_FOR` to custom scripts +* [#7962](https://github.com/netbox-community/netbox/issues/7962) - Fix user menu under report/script result view +* [#7972](https://github.com/netbox-community/netbox/issues/7972) - Standardize name of `RemoteUserBackend` logger +* [#8097](https://github.com/netbox-community/netbox/issues/8097) - Fix styling of Markdown tables +* [#8127](https://github.com/netbox-community/netbox/issues/8127) - Fix disassociation of interface under IP address edit view +* [#8131](https://github.com/netbox-community/netbox/issues/8131) - Restore annotation of available IPs under prefix IPs view +* [#8134](https://github.com/netbox-community/netbox/issues/8134) - Fix bulk editing of objects within dynamic tables +* [#8139](https://github.com/netbox-community/netbox/issues/8139) - Fix rendering of table configuration form under VM interfaces view +* [#8140](https://github.com/netbox-community/netbox/issues/8140) - Restore missing fields on wireless LAN & link REST API serializers + +--- + ## v3.1.2 (2021-12-20) ### Enhancements diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 0822ff206..a668f9b16 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -26,14 +26,12 @@ class ProviderFilterForm(CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -42,8 +40,7 @@ class ProviderFilterForm(CustomFieldModelFilterForm): 'region_id': '$region_id', 'site_group_id': '$site_group_id', }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) asn = forms.IntegerField( required=False, @@ -61,8 +58,7 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm): provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), required=False, - label=_('Provider'), - fetch_trigger='open' + label=_('Provider') ) tag = TagFilterField(model) @@ -84,14 +80,12 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), required=False, - label=_('Type'), - fetch_trigger='open' + label=_('Type') ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), required=False, - label=_('Provider'), - fetch_trigger='open' + label=_('Provider') ) provider_network_id = DynamicModelMultipleChoiceField( queryset=ProviderNetwork.objects.all(), @@ -99,8 +93,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'provider_id': '$provider_id' }, - label=_('Provider network'), - fetch_trigger='open' + label=_('Provider network') ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, @@ -110,14 +103,12 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -126,8 +117,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): 'region_id': '$region_id', 'site_group_id': '$site_group_id', }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) commit_rate = forms.IntegerField( required=False, diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f359f0f24..5830396ce 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -15,14 +15,14 @@ from circuits.models import Circuit from dcim import filtersets from dcim.models import * from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet -from ipam.models import Prefix, VLAN, ASN +from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata from netbox.api.views import ModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model -from utilities.utils import count_related, decode_dict +from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -501,7 +501,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): response[method] = {'error': 'Only get_* NAPALM methods are supported'} continue try: - response[method] = decode_dict(getattr(d, method)()) + response[method] = getattr(d, method)() except NotImplementedError: response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} except Exception as e: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index a1d996b2c..002f12916 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -57,14 +57,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -73,8 +71,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): 'region_id': '$region_id', 'group_id': '$site_group_id', }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), @@ -82,14 +79,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): query_params={ 'site_id': '$site_id', }, - label=_('Location'), - fetch_trigger='open' + label=_('Location') ) virtual_chassis_id = DynamicModelMultipleChoiceField( queryset=VirtualChassis.objects.all(), required=False, - label=_('Virtual Chassis'), - fetch_trigger='open' + label=_('Virtual Chassis') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -99,8 +94,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): 'location_id': '$location_id', 'virtual_chassis_id': '$virtual_chassis_id' }, - label=_('Device'), - fetch_trigger='open' + label=_('Device') ) @@ -109,8 +103,7 @@ class RegionFilterForm(CustomFieldModelFilterForm): parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Parent region'), - fetch_trigger='open' + label=_('Parent region') ) tag = TagFilterField(model) @@ -120,8 +113,7 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm): parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Parent group'), - fetch_trigger='open' + label=_('Parent group') ) tag = TagFilterField(model) @@ -142,20 +134,17 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) asn_id = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), required=False, - label=_('ASNs'), - fetch_trigger='open' + label=_('ASNs') ) tag = TagFilterField(model) @@ -170,14 +159,12 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -186,8 +173,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): 'region_id': '$region_id', 'group_id': '$site_group_id', }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) parent_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), @@ -196,8 +182,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): 'region_id': '$region_id', 'site_id': '$site_id', }, - label=_('Parent'), - fetch_trigger='open' + label=_('Parent') ) tag = TagFilterField(model) @@ -219,8 +204,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -228,8 +212,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), @@ -238,8 +221,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'site_id': '$site_id' }, - label=_('Location'), - fetch_trigger='open' + label=_('Location') ) status = forms.MultipleChoiceField( choices=RackStatusChoices, @@ -260,8 +242,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): queryset=RackRole.objects.all(), required=False, null_option='None', - label=_('Role'), - fetch_trigger='open' + label=_('Role') ) serial = forms.CharField( required=False @@ -280,8 +261,7 @@ class RackElevationFilterForm(RackFilterForm): query_params={ 'site_id': '$site_id', 'location_id': '$location_id', - }, - fetch_trigger='open' + } ) @@ -296,8 +276,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -305,15 +284,13 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.prefetch_related('site'), required=False, label=_('Location'), - null_option='None', - fetch_trigger='open' + null_option='None' ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), @@ -321,8 +298,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', - ), - fetch_trigger='open' + ) ) tag = TagFilterField(model) @@ -342,8 +318,7 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) subdevice_role = forms.MultipleChoiceField( choices=add_blank_choice(SubdeviceRoleChoices), @@ -410,8 +385,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm): manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) tag = TagFilterField(model) @@ -432,14 +406,12 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -448,8 +420,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi 'region_id': '$region_id', 'group_id': '$site_group_id', }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), @@ -458,8 +429,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi query_params={ 'site_id': '$site_id' }, - label=_('Location'), - fetch_trigger='open' + label=_('Location') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), @@ -469,20 +439,17 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi 'site_id': '$site_id', 'location_id': '$location_id', }, - label=_('Rack'), - fetch_trigger='open' + label=_('Rack') ) role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, - label=_('Role'), - fetch_trigger='open' + label=_('Role') ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), @@ -490,15 +457,13 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi query_params={ 'manufacturer_id': '$manufacturer_id' }, - label=_('Model'), - fetch_trigger='open' + label=_('Model') ) platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), required=False, null_option='None', - label=_('Platform'), - fetch_trigger='open' + label=_('Platform') ) status = forms.MultipleChoiceField( choices=DeviceStatusChoices, @@ -589,14 +554,12 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -605,8 +568,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): 'region_id': '$region_id', 'group_id': '$site_group_id', }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) tag = TagFilterField(model) @@ -622,8 +584,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -631,8 +592,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), @@ -641,8 +601,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): null_option='None', query_params={ 'site_id': '$site_id' - }, - fetch_trigger='open' + } ) type = forms.MultipleChoiceField( choices=add_blank_choice(CableTypeChoices), @@ -665,8 +624,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): 'tenant_id': '$tenant_id', 'rack_id': '$rack_id', }, - label=_('Device'), - fetch_trigger='open' + label=_('Device') ) tag = TagFilterField(model) @@ -680,14 +638,12 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -696,8 +652,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm): 'region_id': '$region_id', 'group_id': '$site_group_id', }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), @@ -706,8 +661,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm): query_params={ 'site_id': '$site_id' }, - label=_('Location'), - fetch_trigger='open' + label=_('Location') ) tag = TagFilterField(model) @@ -723,14 +677,12 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -738,8 +690,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) power_panel_id = DynamicModelMultipleChoiceField( queryset=PowerPanel.objects.all(), @@ -748,8 +699,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): query_params={ 'site_id': '$site_id' }, - label=_('Power panel'), - fetch_trigger='open' + label=_('Power panel') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), @@ -758,8 +708,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): query_params={ 'site_id': '$site_id' }, - label=_('Rack'), - fetch_trigger='open' + label=_('Rack') ) status = forms.MultipleChoiceField( choices=PowerFeedStatusChoices, @@ -990,8 +939,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) serial = forms.CharField( required=False @@ -1016,8 +964,7 @@ class ConsoleConnectionFilterForm(FilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -1025,8 +972,7 @@ class ConsoleConnectionFilterForm(FilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -1034,8 +980,7 @@ class ConsoleConnectionFilterForm(FilterForm): query_params={ 'site_id': '$site_id' }, - label=_('Device'), - fetch_trigger='open' + label=_('Device') ) @@ -1043,8 +988,7 @@ class PowerConnectionFilterForm(FilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -1052,8 +996,7 @@ class PowerConnectionFilterForm(FilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -1061,8 +1004,7 @@ class PowerConnectionFilterForm(FilterForm): query_params={ 'site_id': '$site_id' }, - label=_('Device'), - fetch_trigger='open' + label=_('Device') ) @@ -1070,8 +1012,7 @@ class InterfaceConnectionFilterForm(FilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -1079,8 +1020,7 @@ class InterfaceConnectionFilterForm(FilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), @@ -1088,6 +1028,5 @@ class InterfaceConnectionFilterForm(FilterForm): query_params={ 'site_id': '$site_id' }, - label=_('Device'), - fetch_trigger='open' + label=_('Device') ) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index db2f58a63..ca9aa6d3a 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -301,16 +301,14 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): required=False, initial_params={ 'sites': '$site' - }, - fetch_trigger='open' + } ) site_group = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False, initial_params={ 'sites': '$site' - }, - fetch_trigger='open' + } ) site = DynamicModelChoiceField( queryset=Site.objects.all(), @@ -318,24 +316,21 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): query_params={ 'region_id': '$region', 'group_id': '$site_group', - }, - fetch_trigger='open' + } ) location = DynamicModelChoiceField( queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' - }, - fetch_trigger='open' + } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), query_params={ 'site_id': '$site', 'location_id': '$location', - }, - fetch_trigger='open' + } ) units = NumericArrayField( base_field=forms.IntegerField(), @@ -349,8 +344,7 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), - required=False, - fetch_trigger='open' + required=False ) class Meta: diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 58a3e1de5..d74f34828 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -5,42 +5,3 @@ from .devices import * from .power import * from .racks import * from .sites import * - -__all__ = ( - 'BaseInterface', - 'Cable', - 'CablePath', - 'LinkTermination', - 'ConsolePort', - 'ConsolePortTemplate', - 'ConsoleServerPort', - 'ConsoleServerPortTemplate', - 'Device', - 'DeviceBay', - 'DeviceBayTemplate', - 'DeviceRole', - 'DeviceType', - 'FrontPort', - 'FrontPortTemplate', - 'Interface', - 'InterfaceTemplate', - 'InventoryItem', - 'Location', - 'Manufacturer', - 'Platform', - 'PowerFeed', - 'PowerOutlet', - 'PowerOutletTemplate', - 'PowerPanel', - 'PowerPort', - 'PowerPortTemplate', - 'Rack', - 'RackReservation', - 'RackRole', - 'RearPort', - 'RearPortTemplate', - 'Region', - 'Site', - 'SiteGroup', - 'VirtualChassis', -) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f932b7994..0aa8ac2bf 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -111,8 +111,7 @@ class ComponentTemplateTable(BaseTable): class ConsolePortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=ConsolePortTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_consoleports' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -124,8 +123,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=ConsoleServerPortTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_consoleserverports' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -137,8 +135,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=PowerPortTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_powerports' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -150,8 +147,7 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=PowerOutletTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_poweroutlets' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -166,8 +162,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): ) actions = ButtonsColumn( model=InterfaceTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_interfaces' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -183,8 +178,7 @@ class FrontPortTemplateTable(ComponentTemplateTable): color = ColorColumn() actions = ButtonsColumn( model=FrontPortTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_frontports' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -197,8 +191,7 @@ class RearPortTemplateTable(ComponentTemplateTable): color = ColorColumn() actions = ButtonsColumn( model=RearPortTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_rearports' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -210,8 +203,7 @@ class RearPortTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=DeviceBayTemplate, - buttons=('edit', 'delete'), - return_url_extra='%23tab_devicebays' + buttons=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3180d47b1..7048ae63e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -27,13 +27,7 @@ from virtualization.models import VirtualMachine from . import filtersets, forms, tables from .choices import DeviceFaceChoices from .constants import NONCONNECTABLE_IFACE_TYPES -from .models import ( - Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, - PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - SiteGroup, VirtualChassis, -) +from .models import * class DeviceComponentsView(generic.ObjectChildrenView): @@ -51,10 +45,21 @@ class DeviceComponentsView(generic.ObjectChildrenView): class DeviceTypeComponentsView(DeviceComponentsView): queryset = DeviceType.objects.all() template_name = 'dcim/devicetype/component_templates.html' + viewname = None # Used for return_url resolution def get_children(self, request, parent): return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent) + def get_extra_context(self, request, instance): + if self.viewname: + return_url = reverse(self.viewname, kwargs={'pk': instance.pk}) + else: + return_url = instance.get_absolute_url() + return { + 'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}", + 'return_url': return_url, + } + class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ @@ -798,48 +803,56 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView): child_model = ConsolePortTemplate table = tables.ConsolePortTemplateTable filterset = filtersets.ConsolePortTemplateFilterSet + viewname = 'dcim:devicetype_consoleports' class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView): child_model = ConsoleServerPortTemplate table = tables.ConsoleServerPortTemplateTable filterset = filtersets.ConsoleServerPortTemplateFilterSet + viewname = 'dcim:devicetype_consoleserverports' class DeviceTypePowerPortsView(DeviceTypeComponentsView): child_model = PowerPortTemplate table = tables.PowerPortTemplateTable filterset = filtersets.PowerPortTemplateFilterSet + viewname = 'dcim:devicetype_powerports' class DeviceTypePowerOutletsView(DeviceTypeComponentsView): child_model = PowerOutletTemplate table = tables.PowerOutletTemplateTable filterset = filtersets.PowerOutletTemplateFilterSet + viewname = 'dcim:devicetype_poweroutlets' class DeviceTypeInterfacesView(DeviceTypeComponentsView): child_model = InterfaceTemplate table = tables.InterfaceTemplateTable filterset = filtersets.InterfaceTemplateFilterSet + viewname = 'dcim:devicetype_interfaces' class DeviceTypeFrontPortsView(DeviceTypeComponentsView): child_model = FrontPortTemplate table = tables.FrontPortTemplateTable filterset = filtersets.FrontPortTemplateFilterSet + viewname = 'dcim:devicetype_frontports' class DeviceTypeRearPortsView(DeviceTypeComponentsView): child_model = RearPortTemplate table = tables.RearPortTemplateTable filterset = filtersets.RearPortTemplateFilterSet + viewname = 'dcim:devicetype_rearports' class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): child_model = DeviceBayTemplate table = tables.DeviceBayTemplateTable filterset = filtersets.DeviceBayTemplateFilterSet + viewname = 'dcim:devicetype_devicebays' class DeviceTypeEditView(generic.ObjectEditView): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 1be187596..9e4665cc2 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, - NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, + NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer, + NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index fb8cf53e8..9f44494e0 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -3,9 +3,10 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe +from extras.choices import CustomFieldTypeChoices from extras.models import * from extras.utils import FeatureQuery -from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField +from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField __all__ = ( 'CustomFieldCSVForm', @@ -22,6 +23,10 @@ class CustomFieldCSVForm(CSVModelForm): limit_choices_to=FeatureQuery('custom_fields'), help_text="One or more assigned object types" ) + type = CSVChoiceField( + choices=CustomFieldTypeChoices, + help_text='Field data type (e.g. text, integer, etc.)' + ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, @@ -32,7 +37,7 @@ class CustomFieldCSVForm(CSVModelForm): model = CustomField fields = ( 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', - 'choices', 'weight', + 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 07375a203..03cd170b8 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -164,69 +164,58 @@ class ConfigContextFilterForm(FilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Regions'), - fetch_trigger='open' + label=_('Regions') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site groups'), - fetch_trigger='open' + label=_('Site groups') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, - label=_('Sites'), - fetch_trigger='open' + label=_('Sites') ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, - label=_('Device types'), - fetch_trigger='open' + label=_('Device types') ) role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, - label=_('Roles'), - fetch_trigger='open' + label=_('Roles') ) platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), required=False, - label=_('Platforms'), - fetch_trigger='open' + label=_('Platforms') ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, - label=_('Cluster groups'), - fetch_trigger='open' + label=_('Cluster groups') ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label=_('Clusters'), - fetch_trigger='open' + label=_('Clusters') ) tenant_group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), required=False, - label=_('Tenant groups'), - fetch_trigger='open' + label=_('Tenant groups') ) tenant_id = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), required=False, - label=_('Tenant'), - fetch_trigger='open' + label=_('Tenant') ) tag = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', required=False, - label=_('Tags'), - fetch_trigger='open' + label=_('Tags') ) @@ -263,8 +252,7 @@ class JournalEntryFilterForm(FilterForm): label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', - ), - fetch_trigger='open' + ) ) assigned_object_type_id = DynamicModelMultipleChoiceField( queryset=ContentType.objects.all(), @@ -272,8 +260,7 @@ class JournalEntryFilterForm(FilterForm): label=_('Object Type'), widget=APISelectMultiple( api_url='/api/extras/content-types/', - ), - fetch_trigger='open' + ) ) kind = forms.ChoiceField( choices=add_blank_choice(JournalEntryKindChoices), @@ -310,8 +297,7 @@ class ObjectChangeFilterForm(FilterForm): label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', - ), - fetch_trigger='open' + ) ) changed_object_type_id = DynamicModelMultipleChoiceField( queryset=ContentType.objects.all(), @@ -319,6 +305,5 @@ class ObjectChangeFilterForm(FilterForm): label=_('Object Type'), widget=APISelectMultiple( api_url='/api/extras/content-types/', - ), - fetch_trigger='open' + ) ) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 47da21e19..36457efae 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -229,6 +229,24 @@ class CustomLink(ChangeLoggedModel): def get_absolute_url(self): return reverse('extras:customlink', args=[self.pk]) + def render(self, context): + """ + Render the CustomLink given the provided context, and return the text, link, and link_target. + + :param context: The context passed to Jinja2 + """ + text = render_jinja2(self.link_text, context) + if not text: + return {} + link = render_jinja2(self.link_url, context) + link_target = ' target="_blank"' if self.new_window else '' + + return { + 'text': text, + 'link': link, + 'link_target': link_target, + } + @extras_features('webhooks', 'export_templates') class ExportTemplate(ChangeLoggedModel): diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index fec5cf65a..32ec966b3 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -62,16 +62,14 @@ def custom_links(context, obj): # Add non-grouped links else: try: - text_rendered = render_jinja2(cl.link_text, link_context) - if text_rendered: - link_rendered = render_jinja2(cl.link_url, link_context) - link_target = ' target="_blank"' if cl.new_window else '' + rendered = cl.render(link_context) + if rendered: template_code += LINK_BUTTON.format( - link_rendered, link_target, cl.button_class, text_rendered + rendered['link'], rendered['link_target'], cl.button_class, rendered['text'] ) except Exception as e: - template_code += '' \ - ' {}\n'.format(e, cl.name) + template_code += f'' \ + f' {cl.name}\n' # Add grouped links to template for group, links in group_names.items(): @@ -80,17 +78,15 @@ def custom_links(context, obj): for cl in links: try: - text_rendered = render_jinja2(cl.link_text, link_context) - if text_rendered: - link_target = ' target="_blank"' if cl.new_window else '' - link_rendered = render_jinja2(cl.link_url, link_context) + rendered = cl.render(link_context) + if rendered: links_rendered.append( - GROUP_LINK.format(link_rendered, link_target, text_rendered) + GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text']) ) except Exception as e: links_rendered.append( - '
  • ' - ' {}
  • '.format(e, cl.name) + f'
  • ' + f' {cl.name}
  • ' ) if links_rendered: diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 9ce324a5c..67abcf543 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -39,10 +39,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,weight,filter_logic,choices', - 'field4,Field 4,text,dcim.site,100,exact,', - 'field5,Field 5,integer,dcim.site,100,exact,', - 'field6,Field 6,select,dcim.site,100,exact,"A,B,C"', + 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', + 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', + 'field5,Field 5,integer,dcim.site,100,exact,,1,100,', + 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', ) cls.bulk_edit_data = { diff --git a/netbox/extras/views.py b/netbox/extras/views.py index ab9e3ba52..15f3ca48a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -10,6 +10,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm +from utilities.htmx import is_htmx from utilities.tables import paginate_table from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin @@ -471,6 +472,7 @@ class ObjectChangeLogView(View): class ImageAttachmentEditView(generic.ObjectEditView): queryset = ImageAttachment.objects.all() model_form = forms.ImageAttachmentForm + template_name = 'extras/imageattachment_edit.html' def alter_obj(self, instance, request, args, kwargs): if not instance.pk: @@ -693,16 +695,26 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View): def get(self, request, job_result_pk): report_content_type = ContentType.objects.get(app_label='extras', model='report') - jobresult = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type) + result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type) # Retrieve the Report and attach the JobResult to it - module, report_name = jobresult.name.split('.') + module, report_name = result.name.split('.') report = get_report(module, report_name) - report.result = jobresult + report.result = result + + # If this is an HTMX request, return only the result HTML + if is_htmx(request): + response = render(request, 'extras/htmx/report_result.html', { + 'report': report, + 'result': result, + }) + if result.completed: + response.status_code = 286 + return response return render(request, 'extras/report_result.html', { 'report': report, - 'result': jobresult, + 'result': result, }) @@ -820,6 +832,16 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View) script = self._get_script(result.name) + # If this is an HTMX request, return only the result HTML + if is_htmx(request): + response = render(request, 'extras/htmx/script_result.html', { + 'script': script, + 'result': result, + }) + if result.completed: + response.status_code = 286 + return response + return render(request, 'extras/script_result.html', { 'script': script, 'result': result, diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 638ef62f6..526ef07d9 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -135,6 +135,7 @@ class FHRPGroupProtocolChoices(ChoiceSet): PROTOCOL_HSRP = 'hsrp' PROTOCOL_GLBP = 'glbp' PROTOCOL_CARP = 'carp' + PROTOCOL_OTHER = 'other' CHOICES = ( (PROTOCOL_VRRP2, 'VRRPv2'), @@ -142,6 +143,7 @@ class FHRPGroupProtocolChoices(ChoiceSet): (PROTOCOL_HSRP, 'HSRP'), (PROTOCOL_GLBP, 'GLBP'), (PROTOCOL_CARP, 'CARP'), + (PROTOCOL_OTHER, 'Other'), ) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index b21dbd6cd..d0f4c23c9 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -48,14 +48,12 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), required=False, - label=_('Import targets'), - fetch_trigger='open' + label=_('Import targets') ) export_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), required=False, - label=_('Export targets'), - fetch_trigger='open' + label=_('Export targets') ) tag = TagFilterField(model) @@ -70,14 +68,12 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): importing_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, - label=_('Imported by VRF'), - fetch_trigger='open' + label=_('Imported by VRF') ) exporting_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, - label=_('Exported by VRF'), - fetch_trigger='open' + label=_('Exported by VRF') ) tag = TagFilterField(model) @@ -110,8 +106,7 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), required=False, - label=_('RIR'), - fetch_trigger='open' + label=_('RIR') ) tag = TagFilterField(model) @@ -127,14 +122,12 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), required=False, - label=_('RIR'), - fetch_trigger='open' + label=_('RIR') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) @@ -180,14 +173,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): queryset=VRF.objects.all(), required=False, label=_('Assigned VRF'), - null_option='Global', - fetch_trigger='open' + null_option='Global' ) present_in_vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label=_('Present in VRF'), - fetch_trigger='open' + label=_('Present in VRF') ) status = forms.MultipleChoiceField( choices=PrefixStatusChoices, @@ -197,14 +188,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -213,15 +202,13 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'region_id': '$region_id' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) role_id = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), required=False, null_option='None', - label=_('Role'), - fetch_trigger='open' + label=_('Role') ) is_pool = forms.NullBooleanField( required=False, @@ -257,8 +244,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): queryset=VRF.objects.all(), required=False, label=_('Assigned VRF'), - null_option='Global', - fetch_trigger='open' + null_option='Global' ) status = forms.MultipleChoiceField( choices=PrefixStatusChoices, @@ -269,8 +255,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): queryset=Role.objects.all(), required=False, null_option='None', - label=_('Role'), - fetch_trigger='open' + label=_('Role') ) tag = TagFilterField(model) @@ -308,14 +293,12 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): queryset=VRF.objects.all(), required=False, label=_('Assigned VRF'), - null_option='Global', - fetch_trigger='open' + null_option='Global' ) present_in_vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label=_('Present in VRF'), - fetch_trigger='open' + label=_('Present in VRF') ) status = forms.MultipleChoiceField( choices=IPAddressStatusChoices, @@ -376,32 +359,27 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) sitegroup = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) location = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), required=False, - label=_('Location'), - fetch_trigger='open' + label=_('Location') ) rack = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label=_('Rack'), - fetch_trigger='open' + label=_('Rack') ) tag = TagFilterField(model) @@ -417,14 +395,12 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, - label=_('Region'), - fetch_trigger='open' + label=_('Region') ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, - label=_('Site group'), - fetch_trigger='open' + label=_('Site group') ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -433,8 +409,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'region': '$region' }, - label=_('Site'), - fetch_trigger='open' + label=_('Site') ) group_id = DynamicModelMultipleChoiceField( queryset=VLANGroup.objects.all(), @@ -443,8 +418,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): query_params={ 'region': '$region' }, - label=_('VLAN group'), - fetch_trigger='open' + label=_('VLAN group') ) status = forms.MultipleChoiceField( choices=VLANStatusChoices, @@ -455,8 +429,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): queryset=Role.objects.all(), required=False, null_option='None', - label=_('Role'), - fetch_trigger='open' + label=_('Role') ) vid = forms.IntegerField( required=False, diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 319d8671e..c5e3146e9 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -471,6 +471,8 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm): }) elif selected_objects: self.instance.assigned_object = self.cleaned_data[selected_objects[0]] + else: + self.instance.assigned_object = None # Primary IP assignment is only available if an interface has been assigned. interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index cff845a7a..317caeaf2 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -5,18 +5,18 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from dcim.filtersets import InterfaceFilterSet -from dcim.models import Device, Interface, Site +from dcim.models import Interface, Site from dcim.tables import SiteTable from netbox.views import generic from utilities.tables import paginate_table from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VirtualMachine, VMInterface +from virtualization.models import VMInterface from . import filtersets, forms, tables from .constants import * from .models import * from .models import ASN -from .utils import add_requested_prefixes, add_available_vlans +from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans # @@ -418,7 +418,7 @@ class PrefixView(generic.ObjectView): ).filter( prefix__net_contains=str(instance.prefix) ).prefetch_related( - 'site', 'role' + 'site', 'role', 'tenant' ) parent_prefix_table = tables.PrefixTable( list(parent_prefixes), @@ -502,6 +502,13 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view') + def prep_table_data(self, request, queryset, parent): + show_available = bool(request.GET.get('show_available', 'true') == 'true') + if show_available: + return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool) + + return queryset + def get_extra_context(self, request, instance): return { 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index a67ec451d..acb04ce34 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -105,7 +105,7 @@ class RemoteUserBackend(_RemoteUserBackend): return settings.REMOTE_AUTH_AUTO_CREATE_USER def configure_groups(self, user, remote_groups): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') # Assign default groups to the user group_list = [] @@ -141,7 +141,7 @@ class RemoteUserBackend(_RemoteUserBackend): Return None if ``create_unknown_user`` is ``False`` and a ``User`` object with the given username is not found in the database. """ - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') logger.debug( f"trying to authenticate {remote_user} with groups {remote_groups}") if not remote_user: @@ -173,7 +173,7 @@ class RemoteUserBackend(_RemoteUserBackend): return None def _is_superuser(self, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS logger.debug(f"Superuser Groups: {superuser_groups}") superusers = settings.REMOTE_AUTH_SUPERUSERS @@ -189,7 +189,7 @@ class RemoteUserBackend(_RemoteUserBackend): return bool(result) def _is_staff(self, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS logger.debug(f"Superuser Groups: {staff_groups}") staff_users = settings.REMOTE_AUTH_STAFF_USERS @@ -204,7 +204,7 @@ class RemoteUserBackend(_RemoteUserBackend): return bool(result) def configure_user(self, request, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') + logger = logging.getLogger('netbox.auth.RemoteUserBackend') if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED: # Assign default groups to the user group_list = [] diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index af4ce0a4d..9c2fb0174 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.2' +VERSION = '3.1.3' # Hostname HOSTNAME = platform.node() diff --git a/netbox/project-static/bundle.js b/netbox/project-static/bundle.js index 100b70ac8..76a1581ad 100644 --- a/netbox/project-static/bundle.js +++ b/netbox/project-static/bundle.js @@ -40,7 +40,6 @@ async function bundleGraphIQL() { async function bundleNetBox() { const entryPoints = { netbox: 'src/index.ts', - jobs: 'src/jobs.ts', lldp: 'src/device/lldp.ts', config: 'src/device/config.ts', status: 'src/device/status.ts', diff --git a/netbox/project-static/dist/jobs.js b/netbox/project-static/dist/jobs.js deleted file mode 100644 index 2aedf1219..000000000 Binary files a/netbox/project-static/dist/jobs.js and /dev/null differ diff --git a/netbox/project-static/dist/jobs.js.map b/netbox/project-static/dist/jobs.js.map deleted file mode 100644 index d7c1dbcbf..000000000 Binary files a/netbox/project-static/dist/jobs.js.map and /dev/null differ diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 25017505e..e711685bf 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 07ad0dba2..23dc8d382 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 a09f49222..dde212a6c 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/dist/netbox.js b/netbox/project-static/dist/netbox.js index 95fd99270..33b94b478 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 6fbe0874b..eb6b85087 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/elements.ts b/netbox/project-static/src/forms/elements.ts index 5cb17f5c7..9e2ae67c4 100644 --- a/netbox/project-static/src/forms/elements.ts +++ b/netbox/project-static/src/forms/elements.ts @@ -35,11 +35,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void { for (const element of form.querySelectorAll('*[name]')) { if (!element.validity.valid) { invalids.add(element.name); - - // If the field is invalid, but contains the .is-valid class, remove it. - if (element.classList.contains('is-valid')) { - element.classList.remove('is-valid'); - } // If the field is invalid, but doesn't contain the .is-invalid class, add it. if (!element.classList.contains('is-invalid')) { element.classList.add('is-invalid'); @@ -49,10 +44,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void { if (element.classList.contains('is-invalid')) { element.classList.remove('is-invalid'); } - // If the field is valid, but doesn't contain the .is-valid class, add it. - if (!element.classList.contains('is-valid')) { - element.classList.add('is-valid'); - } } } diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts index bad12c795..89c106e9c 100644 --- a/netbox/project-static/src/global.d.ts +++ b/netbox/project-static/src/global.d.ts @@ -98,38 +98,6 @@ type APISecret = { url: string; }; -type JobResultLog = { - message: string; - status: 'success' | 'warning' | 'danger' | 'info'; -}; - -type JobStatus = { - label: string; - value: 'completed' | 'failed' | 'errored' | 'running'; -}; - -type APIJobResult = { - completed: string; - created: string; - data: { - log: JobResultLog[]; - output: string; - }; - display: string; - id: number; - job_id: string; - name: string; - obj_type: string; - status: JobStatus; - url: string; - user: { - display: string; - username: string; - id: number; - url: string; - }; -}; - type APIUserConfig = { tables: { [k: string]: { columns: string[]; available_columns: string[] } }; [k: string]: unknown; diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts new file mode 100644 index 000000000..70ed4f534 --- /dev/null +++ b/netbox/project-static/src/htmx.ts @@ -0,0 +1,23 @@ +import { getElements, isTruthy } from './util'; +import { initButtons } from './buttons'; + +function initDepedencies(): void { + for (const init of [initButtons]) { + init(); + } +} + +/** + * Hook into HTMX's event system to reinitialize specific native event listeners when HTMX swaps + * elements. + */ +export function initHtmx(): void { + for (const element of getElements('[hx-target]')) { + const targetSelector = element.getAttribute('hx-target'); + if (isTruthy(targetSelector)) { + for (const target of getElements(targetSelector)) { + target.addEventListener('htmx:afterSettle', initDepedencies); + } + } + } +} diff --git a/netbox/project-static/src/jobs.ts b/netbox/project-static/src/jobs.ts deleted file mode 100644 index dedf0706d..000000000 --- a/netbox/project-static/src/jobs.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createToast } from './bs'; -import { apiGetBase, hasError, getNetboxData } from './util'; - -let timeout: number = 1000; - -interface JobInfo { - url: Nullable; - complete: boolean; -} - -/** - * Mimic the behavior of setTimeout() in an async function. - */ -function asyncTimeout(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * Job ID & Completion state are only from Django context, which can only be used from the HTML - * template. Hidden elements are present in the template to provide access to these values from - * JavaScript. - */ -function getJobInfo(): JobInfo { - let complete = false; - - // Determine the API URL for the job status - const url = getNetboxData('data-job-url'); - - // Determine the job completion status, if present. If the job is not complete, the value will be - // "None". Otherwise, it will be a stringified date. - const jobComplete = getNetboxData('data-job-complete'); - if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') { - complete = true; - } - return { url, complete }; -} - -/** - * Update the job status label element based on the API response. - */ -function updateLabel(status: JobStatus) { - const element = document.querySelector('#pending-result-label > span.badge'); - if (element !== null) { - let labelClass = 'secondary'; - switch (status.value) { - case 'failed' || 'errored': - labelClass = 'danger'; - break; - case 'running': - labelClass = 'warning'; - break; - case 'completed': - labelClass = 'success'; - break; - } - element.setAttribute('class', `badge bg-${labelClass}`); - element.innerText = status.label; - } -} - -/** - * Recursively check the job's status. - * @param url API URL for job result - */ -async function checkJobStatus(url: string) { - const res = await apiGetBase(url); - if (hasError(res)) { - // If the response is an API error, display an error message and stop checking for job status. - const toast = createToast('danger', 'Error', res.error); - toast.show(); - return; - } else { - // Update the job status label. - updateLabel(res.status); - - // If the job is complete, reload the page. - if (['completed', 'failed', 'errored'].includes(res.status.value)) { - location.reload(); - return; - } else { - // Otherwise, keep checking the job's status, backing off 1 second each time, until a 10 - // second interval is reached. - if (timeout < 10000) { - timeout += 1000; - } - await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]); - } - } -} - -function initJobs() { - const { url, complete } = getJobInfo(); - - if (url !== null && !complete) { - // If there is a job ID and it is not completed, check for the job's status. - Promise.resolve(checkJobStatus(url)); - } -} - -if (document.readyState !== 'loading') { - initJobs(); -} else { - document.addEventListener('DOMContentLoaded', initJobs); -} diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index 79c196b96..c178a2dbd 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -12,6 +12,7 @@ import { initInterfaceTable } from './tables'; import { initSideNav } from './sidenav'; import { initRackElevation } from './racks'; import { initLinks } from './links'; +import { initHtmx } from './htmx'; function initDocument(): void { for (const init of [ @@ -29,6 +30,7 @@ function initDocument(): void { initSideNav, initRackElevation, initLinks, + initHtmx, ]) { init(); } diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 032fc83fa..f24c3fa5b 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -251,7 +251,7 @@ export class APISelect { } else if (collapse !== null) { this.trigger = 'collapse'; } else { - this.trigger = 'load'; + this.trigger = 'open'; } switch (this.trigger) { diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index acbfa0646..d78429bf9 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -965,6 +965,19 @@ div.card-overlay { max-width: unset; } +/* Rendered Markdown */ +.rendered-markdown table { + width: 100%; +} +.rendered-markdown th { + border-bottom: 2px solid #dddddd; + padding: 8px; +} +.rendered-markdown td { + border-top: 1px solid #dddddd; + padding: 8px; +} + // Preformatted text blocks td pre { margin-bottom: 0 diff --git a/netbox/project-static/styles/theme-light.scss b/netbox/project-static/styles/theme-light.scss index c14f7f314..4e638c75e 100644 --- a/netbox/project-static/styles/theme-light.scss +++ b/netbox/project-static/styles/theme-light.scss @@ -8,6 +8,7 @@ $theme-colors: map-merge( $theme-colors, ( 'primary': #337ab7, + 'info': #54d6f0, 'red': $red-500, 'yellow': $yellow-500, 'green': $green-500, diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 50bf7133c..6e71b3995 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -104,23 +104,23 @@ {# Static resources #} @@ -129,7 +129,7 @@ {# Javascript #} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 38c1dc21b..7b1597bf0 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -1,8 +1,7 @@ {# Base layout for the core NetBox UI w/navbar and page content #} {% extends 'base/base.html' %} {% load helpers %} -{% load nav %} -{% load search_options %} +{% load search %} {% load static %} {% block layout %} @@ -21,7 +20,7 @@ {# Top bar #} -