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