mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
f16c6d81cf
@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
|||||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
@ -267,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring
|
|||||||
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
|
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||||
|
@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq
|
|||||||
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
|
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||||
|
@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
## v3.0.11 (FUTURE)
|
## v3.0.11 (FUTURE)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models
|
||||||
|
* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components
|
||||||
|
* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting
|
||||||
|
* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table
|
||||||
|
* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts
|
||||||
|
* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled
|
||||||
|
* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values
|
||||||
|
* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table
|
||||||
|
* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination
|
||||||
|
* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering
|
||||||
|
* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines
|
||||||
|
* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status
|
||||||
|
* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views
|
||||||
|
* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API
|
||||||
|
* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v3.0.10 (2021-11-12)
|
## v3.0.10 (2021-11-12)
|
||||||
|
@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.VirtualChassis
|
model = models.VirtualChassis
|
||||||
fields = ['id', 'name', 'url', 'master', 'member_count']
|
fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -352,7 +352,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
power_port = NestedPowerPortTemplateSerializer(
|
power_port = NestedPowerPortTemplateSerializer(
|
||||||
required=False
|
required=False,
|
||||||
|
allow_null=True
|
||||||
)
|
)
|
||||||
feed_leg = ChoiceField(
|
feed_leg = ChoiceField(
|
||||||
choices=PowerOutletFeedLegChoices,
|
choices=PowerOutletFeedLegChoices,
|
||||||
@ -524,7 +525,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali
|
|||||||
)
|
)
|
||||||
speed = ChoiceField(
|
speed = ChoiceField(
|
||||||
choices=ConsolePortSpeedChoices,
|
choices=ConsolePortSpeedChoices,
|
||||||
allow_blank=True,
|
allow_null=True,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
cable = NestedCableSerializer(read_only=True)
|
cable = NestedCableSerializer(read_only=True)
|
||||||
@ -548,7 +549,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
|
|||||||
)
|
)
|
||||||
speed = ChoiceField(
|
speed = ChoiceField(
|
||||||
choices=ConsolePortSpeedChoices,
|
choices=ConsolePortSpeedChoices,
|
||||||
allow_blank=True,
|
allow_null=True,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
cable = NestedCableSerializer(read_only=True)
|
cable = NestedCableSerializer(read_only=True)
|
||||||
@ -571,7 +572,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
power_port = NestedPowerPortSerializer(
|
power_port = NestedPowerPortSerializer(
|
||||||
required=False
|
required=False,
|
||||||
|
allow_null=True
|
||||||
)
|
)
|
||||||
feed_leg = ChoiceField(
|
feed_leg = ChoiceField(
|
||||||
choices=PowerOutletFeedLegChoices,
|
choices=PowerOutletFeedLegChoices,
|
||||||
|
@ -759,6 +759,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_80211AC = 'ieee802.11ac'
|
TYPE_80211AC = 'ieee802.11ac'
|
||||||
TYPE_80211AD = 'ieee802.11ad'
|
TYPE_80211AD = 'ieee802.11ad'
|
||||||
TYPE_80211AX = 'ieee802.11ax'
|
TYPE_80211AX = 'ieee802.11ax'
|
||||||
|
TYPE_802151 = 'ieee802.15.1'
|
||||||
|
|
||||||
# Cellular
|
# Cellular
|
||||||
TYPE_GSM = 'gsm'
|
TYPE_GSM = 'gsm'
|
||||||
@ -871,6 +872,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_80211AC, 'IEEE 802.11ac'),
|
(TYPE_80211AC, 'IEEE 802.11ac'),
|
||||||
(TYPE_80211AD, 'IEEE 802.11ad'),
|
(TYPE_80211AD, 'IEEE 802.11ad'),
|
||||||
(TYPE_80211AX, 'IEEE 802.11ax'),
|
(TYPE_80211AX, 'IEEE 802.11ax'),
|
||||||
|
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -876,6 +876,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
label='Device (name)',
|
label='Device (name)',
|
||||||
)
|
)
|
||||||
|
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='device__virtual_chassis',
|
||||||
|
queryset=VirtualChassis.objects.all(),
|
||||||
|
label='Virtual Chassis (ID)'
|
||||||
|
)
|
||||||
|
virtual_chassis = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='device__virtual_chassis__name',
|
||||||
|
queryset=VirtualChassis.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
label='Virtual Chassis',
|
||||||
|
)
|
||||||
tag = TagFilter()
|
tag = TagFilter()
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
@ -1416,6 +1427,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ConnectionFilterSet(BaseFilterSet):
|
class ConnectionFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
site_id = MultiValueNumberFilter(
|
site_id = MultiValueNumberFilter(
|
||||||
method='filter_connections',
|
method='filter_connections',
|
||||||
field_name='device__site_id'
|
field_name='device__site_id'
|
||||||
@ -1438,6 +1453,15 @@ class ConnectionFilterSet(BaseFilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(**{f'{name}__in': value})
|
return queryset.filter(**{f'{name}__in': value})
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
qs_filter = (
|
||||||
|
Q(device__name__icontains=value) |
|
||||||
|
Q(cable__label__icontains=value)
|
||||||
|
)
|
||||||
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class ConsoleConnectionFilterSet(ConnectionFilterSet):
|
class ConsoleConnectionFilterSet(ConnectionFilterSet):
|
||||||
|
|
||||||
|
@ -217,8 +217,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFi
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta(ConnectCableToDeviceForm.Meta):
|
||||||
model = Cable
|
|
||||||
fields = [
|
fields = [
|
||||||
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
|
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
|
||||||
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
|
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||||
@ -280,8 +279,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelF
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta(ConnectCableToDeviceForm.Meta):
|
||||||
model = Cable
|
|
||||||
fields = [
|
fields = [
|
||||||
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
|
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
|
||||||
'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
|
'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||||
|
@ -93,12 +93,19 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
|||||||
label=_('Location'),
|
label=_('Location'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
|
virtual_chassis_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VirtualChassis.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Virtual Chassis'),
|
||||||
|
fetch_trigger='open'
|
||||||
|
)
|
||||||
device_id = DynamicModelMultipleChoiceField(
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
query_params={
|
||||||
'site_id': '$site_id',
|
'site_id': '$site_id',
|
||||||
'location_id': '$location_id',
|
'location_id': '$location_id',
|
||||||
|
'virtual_chassis_id': '$virtual_chassis_id'
|
||||||
},
|
},
|
||||||
label=_('Device'),
|
label=_('Device'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
@ -895,7 +902,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'type', 'speed'],
|
['name', 'label', 'type', 'speed'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=ConsolePortTypeChoices,
|
choices=ConsolePortTypeChoices,
|
||||||
@ -915,7 +922,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'type', 'speed'],
|
['name', 'label', 'type', 'speed'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=ConsolePortTypeChoices,
|
choices=ConsolePortTypeChoices,
|
||||||
@ -935,7 +942,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'type'],
|
['name', 'label', 'type'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=PowerPortTypeChoices,
|
choices=PowerPortTypeChoices,
|
||||||
@ -950,7 +957,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'type'],
|
['name', 'label', 'type'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=PowerOutletTypeChoices,
|
choices=PowerOutletTypeChoices,
|
||||||
@ -966,7 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
|||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
|
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
|
||||||
['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
|
['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
kind = forms.MultipleChoiceField(
|
kind = forms.MultipleChoiceField(
|
||||||
choices=InterfaceKindChoices,
|
choices=InterfaceKindChoices,
|
||||||
@ -1031,7 +1038,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'type', 'color'],
|
['name', 'label', 'type', 'color'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
model = FrontPort
|
model = FrontPort
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
@ -1050,7 +1057,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'type', 'color'],
|
['name', 'label', 'type', 'color'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
choices=PortTypeChoices,
|
choices=PortTypeChoices,
|
||||||
@ -1068,7 +1075,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label'],
|
['name', 'label'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
@ -1078,7 +1085,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
|||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag'],
|
||||||
['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
|
['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
|
||||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||||
]
|
]
|
||||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
@ -1106,6 +1113,11 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||||
|
q = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||||
|
label=_('Search')
|
||||||
|
)
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -1133,6 +1145,11 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||||
|
q = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||||
|
label=_('Search')
|
||||||
|
)
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -1160,6 +1177,11 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||||
|
q = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||||
|
label=_('Search')
|
||||||
|
)
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -478,6 +478,7 @@ class CableTraceSVG:
|
|||||||
parent_objects.append(parent_object)
|
parent_objects.append(parent_object)
|
||||||
|
|
||||||
# Near end termination
|
# Near end termination
|
||||||
|
if near_end is not None:
|
||||||
termination = self._draw_box(
|
termination = self._draw_box(
|
||||||
width=self.width * .8,
|
width=self.width * .8,
|
||||||
color=self._get_color(near_end),
|
color=self._get_color(near_end),
|
||||||
|
@ -595,6 +595,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
power_port_templates = (
|
||||||
|
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
|
||||||
|
PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
|
||||||
|
)
|
||||||
|
PowerPortTemplate.objects.bulk_create(power_port_templates)
|
||||||
|
|
||||||
power_outlet_templates = (
|
power_outlet_templates = (
|
||||||
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
|
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
|
||||||
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
|
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
|
||||||
@ -606,14 +612,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
|||||||
{
|
{
|
||||||
'device_type': devicetype.pk,
|
'device_type': devicetype.pk,
|
||||||
'name': 'Power Outlet Template 4',
|
'name': 'Power Outlet Template 4',
|
||||||
|
'power_port': power_port_templates[0].pk,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device_type': devicetype.pk,
|
'device_type': devicetype.pk,
|
||||||
'name': 'Power Outlet Template 5',
|
'name': 'Power Outlet Template 5',
|
||||||
|
'power_port': power_port_templates[1].pk,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device_type': devicetype.pk,
|
'device_type': devicetype.pk,
|
||||||
'name': 'Power Outlet Template 6',
|
'name': 'Power Outlet Template 6',
|
||||||
|
'power_port': None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1044,14 +1053,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
|||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Console Port 4',
|
'name': 'Console Port 4',
|
||||||
|
'speed': 9600,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Console Port 5',
|
'name': 'Console Port 5',
|
||||||
|
'speed': 115200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Console Port 6',
|
'name': 'Console Port 6',
|
||||||
|
'speed': None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1083,14 +1095,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
|
|||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Console Server Port 4',
|
'name': 'Console Server Port 4',
|
||||||
|
'speed': 9600,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Console Server Port 5',
|
'name': 'Console Server Port 5',
|
||||||
|
'speed': 115200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Console Server Port 6',
|
'name': 'Console Server Port 6',
|
||||||
|
'speed': None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1150,6 +1165,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
|||||||
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
|
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
|
||||||
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
|
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
|
||||||
|
|
||||||
|
power_ports = (
|
||||||
|
PowerPort(device=device, name='Power Port 1'),
|
||||||
|
PowerPort(device=device, name='Power Port 2'),
|
||||||
|
)
|
||||||
|
PowerPort.objects.bulk_create(power_ports)
|
||||||
|
|
||||||
power_outlets = (
|
power_outlets = (
|
||||||
PowerOutlet(device=device, name='Power Outlet 1'),
|
PowerOutlet(device=device, name='Power Outlet 1'),
|
||||||
PowerOutlet(device=device, name='Power Outlet 2'),
|
PowerOutlet(device=device, name='Power Outlet 2'),
|
||||||
@ -1161,14 +1182,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
|||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Power Outlet 4',
|
'name': 'Power Outlet 4',
|
||||||
|
'power_port': power_ports[0].pk,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Power Outlet 5',
|
'name': 'Power Outlet 5',
|
||||||
|
'power_port': power_ports[1].pk,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': device.pk,
|
'device': device.pk,
|
||||||
'name': 'Power Outlet 6',
|
'name': 'Power Outlet 6',
|
||||||
|
'power_port': None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1548,7 +1572,7 @@ class ConnectedDeviceTest(APITestCase):
|
|||||||
|
|
||||||
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = VirtualChassis
|
model = VirtualChassis
|
||||||
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
|
brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
@ -2073,6 +2073,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
)
|
)
|
||||||
Device.objects.bulk_create(devices)
|
Device.objects.bulk_create(devices)
|
||||||
|
|
||||||
|
# VirtualChassis assignment for filtering
|
||||||
|
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
||||||
|
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
|
||||||
|
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||||
|
|
||||||
interfaces = (
|
interfaces = (
|
||||||
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
|
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
|
||||||
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
|
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
|
||||||
@ -2197,6 +2202,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_virtual_chassis_id(self):
|
||||||
|
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_device(self):
|
def test_device(self):
|
||||||
devices = Device.objects.all()[:2]
|
devices = Device.objects.all()[:2]
|
||||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||||
|
@ -28,6 +28,10 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
class WebhookFilterSet(BaseFilterSet):
|
class WebhookFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
content_types = ContentTypeFilter()
|
content_types = ContentTypeFilter()
|
||||||
http_method = django_filters.MultipleChoiceFilter(
|
http_method = django_filters.MultipleChoiceFilter(
|
||||||
choices=WebhookHttpMethodChoices
|
choices=WebhookHttpMethodChoices
|
||||||
@ -40,30 +44,81 @@ class WebhookFilterSet(BaseFilterSet):
|
|||||||
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(payload_url__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldFilterSet(BaseFilterSet):
|
class CustomFieldFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
content_types = ContentTypeFilter()
|
content_types = ContentTypeFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(label__icontains=value) |
|
||||||
|
Q(description__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkFilterSet(BaseFilterSet):
|
class CustomLinkFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomLink
|
model = CustomLink
|
||||||
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
|
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(link_text__icontains=value) |
|
||||||
|
Q(link_url__icontains=value) |
|
||||||
|
Q(group_name__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateFilterSet(BaseFilterSet):
|
class ExportTemplateFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
fields = ['id', 'content_type', 'name']
|
fields = ['id', 'content_type', 'name']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(description__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImageAttachmentFilterSet(BaseFilterSet):
|
class ImageAttachmentFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
created = django_filters.DateTimeFilter()
|
created = django_filters.DateTimeFilter()
|
||||||
content_type = ContentTypeFilter()
|
content_type = ContentTypeFilter()
|
||||||
|
|
||||||
@ -71,6 +126,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
|||||||
model = ImageAttachment
|
model = ImageAttachment
|
||||||
fields = ['id', 'content_type_id', 'object_id', 'name']
|
fields = ['id', 'content_type_id', 'object_id', 'name']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(name__icontains=value)
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
|
class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
|
@ -3,6 +3,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
@ -477,6 +478,10 @@ def get_scripts(use_names=False):
|
|||||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||||
# defined.
|
# defined.
|
||||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||||
|
# Remove cached module to ensure consistency with filesystem
|
||||||
|
if module_name in sys.modules:
|
||||||
|
del sys.modules[module_name]
|
||||||
|
|
||||||
module = importer.find_module(module_name).load_module(module_name)
|
module = importer.find_module(module_name).load_module(module_name)
|
||||||
if use_names and hasattr(module, 'name'):
|
if use_names and hasattr(module, 'name'):
|
||||||
module_name = module.name
|
module_name = module.name
|
||||||
|
@ -11,7 +11,7 @@ from rq import Worker
|
|||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.tables import paginate_table
|
from utilities.tables import paginate_table
|
||||||
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
|
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
|
||||||
from utilities.views import ContentTypePermissionRequiredMixin
|
from utilities.views import ContentTypePermissionRequiredMixin
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .choices import JobResultStatusChoices
|
from .choices import JobResultStatusChoices
|
||||||
@ -754,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
|||||||
|
|
||||||
def get(self, request, module, name):
|
def get(self, request, module, name):
|
||||||
script = self._get_script(name, module)
|
script = self._get_script(name, module)
|
||||||
form = script.as_form(initial=request.GET)
|
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||||
|
|
||||||
# Look for a pending JobResult (use the latest one by creation timestamp)
|
# Look for a pending JobResult (use the latest one by creation timestamp)
|
||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
|
@ -235,6 +235,11 @@ class PrefixTable(BaseTable):
|
|||||||
site = tables.Column(
|
site = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
vlan_group = tables.Column(
|
||||||
|
accessor='vlan__group',
|
||||||
|
linkify=True,
|
||||||
|
verbose_name='VLAN Group'
|
||||||
|
)
|
||||||
vlan = tables.Column(
|
vlan = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name='VLAN'
|
verbose_name='VLAN'
|
||||||
@ -259,8 +264,8 @@ class PrefixTable(BaseTable):
|
|||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Prefix
|
model = Prefix
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
|
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
|
||||||
'is_pool', 'mark_utilized', 'description', 'tags',
|
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
|
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
|
||||||
@ -347,7 +352,7 @@ class IPAddressTable(BaseTable):
|
|||||||
verbose_name='NAT (Inside)'
|
verbose_name='NAT (Inside)'
|
||||||
)
|
)
|
||||||
assigned = BooleanColumn(
|
assigned = BooleanColumn(
|
||||||
accessor='assigned_object',
|
accessor='assigned_object_id',
|
||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name='Assigned'
|
verbose_name='Assigned'
|
||||||
)
|
)
|
||||||
|
@ -34,7 +34,7 @@ class ObjectPermissionMixin():
|
|||||||
object_permissions = ObjectPermission.objects.filter(
|
object_permissions = ObjectPermission.objects.filter(
|
||||||
self.get_permission_filter(user_obj),
|
self.get_permission_filter(user_obj),
|
||||||
enabled=True
|
enabled=True
|
||||||
).prefetch_related('object_types')
|
).order_by('id').distinct('id').prefetch_related('object_types')
|
||||||
|
|
||||||
# Create a dictionary mapping permissions to their constraints
|
# Create a dictionary mapping permissions to their constraints
|
||||||
perms = defaultdict(list)
|
perms = defaultdict(list)
|
||||||
|
@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
|||||||
def get_required_permission(self):
|
def get_required_permission(self):
|
||||||
return get_permission_for_model(self.queryset.model, 'view')
|
return get_permission_for_model(self.queryset.model, 'view')
|
||||||
|
|
||||||
|
def get_table(self, request, permissions):
|
||||||
|
table = self.table(self.queryset, user=request.user)
|
||||||
|
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
||||||
|
table.columns.show('pk')
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
def export_yaml(self):
|
def export_yaml(self):
|
||||||
"""
|
"""
|
||||||
Export the queryset of objects as concatenated YAML documents.
|
Export the queryset of objects as concatenated YAML documents.
|
||||||
@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
|||||||
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
|
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, request):
|
def export_template(self, template, request):
|
||||||
|
"""
|
||||||
|
Render an ExportTemplate using the current queryset.
|
||||||
|
|
||||||
|
:param template: ExportTemplate instance
|
||||||
|
:param request: The current request
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return template.render_to_response(self.queryset)
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
|
||||||
|
return redirect(request.path)
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
model = self.queryset.model
|
model = self.queryset.model
|
||||||
content_type = ContentType.objects.get_for_model(model)
|
content_type = ContentType.objects.get_for_model(model)
|
||||||
|
|
||||||
@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
|||||||
perm_name = get_permission_for_model(model, action)
|
perm_name = get_permission_for_model(model, action)
|
||||||
permissions[action] = request.user.has_perm(perm_name)
|
permissions[action] = request.user.has_perm(perm_name)
|
||||||
|
|
||||||
# Export template/YAML rendering
|
if 'export' in request.GET:
|
||||||
if 'export' in request.GET and request.GET['export'] != 'table':
|
|
||||||
|
|
||||||
# An export template has been specified
|
# Export the current table view
|
||||||
if request.GET['export']:
|
if request.GET['export'] == 'table':
|
||||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
table = self.get_table(request, permissions)
|
||||||
try:
|
columns = [name for name, _ in table.selected_columns]
|
||||||
return et.render_to_response(self.queryset)
|
return self.export_table(table, columns)
|
||||||
except Exception as e:
|
|
||||||
messages.error(
|
|
||||||
request,
|
|
||||||
"There was an error rendering the selected export template ({}): {}".format(
|
|
||||||
et.name, e
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for YAML export support
|
# Render an ExportTemplate
|
||||||
|
elif request.GET['export']:
|
||||||
|
template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||||
|
return self.export_template(template, request)
|
||||||
|
|
||||||
|
# Check for YAML export support on the model
|
||||||
elif hasattr(model, 'to_yaml'):
|
elif hasattr(model, 'to_yaml'):
|
||||||
response = HttpResponse(self.export_yaml(), content_type='text/yaml')
|
response = HttpResponse(self.export_yaml(), content_type='text/yaml')
|
||||||
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
|
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
|
||||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# Construct the objects table
|
# Fall back to default table/YAML export
|
||||||
table = self.table(self.queryset, user=request.user)
|
else:
|
||||||
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
table = self.get_table(request, permissions)
|
||||||
table.columns.show('pk')
|
|
||||||
|
|
||||||
# Handle table-based exports (current view or static CSV-based)
|
|
||||||
if request.GET.get('export') == 'table':
|
|
||||||
columns = [name for name, _ in table.selected_columns]
|
|
||||||
return self.export_table(table, columns)
|
|
||||||
elif 'export' in request.GET:
|
|
||||||
return self.export_table(table)
|
return self.export_table(table)
|
||||||
|
|
||||||
# Paginate the objects table
|
# Render the objects table
|
||||||
|
table = self.get_table(request, permissions)
|
||||||
paginate_table(table, request)
|
paginate_table(table, request)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
@ -3,22 +3,32 @@
|
|||||||
|
|
||||||
{% block title %}Reports{% endblock %}
|
{% block title %}Reports{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block tabs %}
|
||||||
<div class="row">
|
<ul class="nav nav-tabs px-3">
|
||||||
<div class="col col-md-9">
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link active" role="tab">Reports</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock tabs %}
|
||||||
|
|
||||||
|
{% block content-wrapper %}
|
||||||
|
<div class="tab-content">
|
||||||
{% if reports %}
|
{% if reports %}
|
||||||
{% for module, module_reports in reports %}
|
{% for module, module_reports in reports %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header"><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h5>
|
<h5 class="card-header">
|
||||||
|
<a name="module.{{ module }}"></a>
|
||||||
|
<i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
|
||||||
|
</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-hover table-headings reports">
|
<table class="table table-hover table-headings reports">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th width="250">Name</th>
|
||||||
<th>Status</th>
|
<th width="110">Status</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th class="text-end">Last Run</th>
|
<th width="150" class="text-end">Last Run</th>
|
||||||
<th></th>
|
<th width="120"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -43,7 +53,7 @@
|
|||||||
<div class="float-end noprint">
|
<div class="float-end noprint">
|
||||||
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
|
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" name="_run" class="btn btn-primary btn-sm">
|
<button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
|
||||||
{% if report.result %}
|
{% if report.result %}
|
||||||
<i class="mdi mdi-replay"></i> Run Again
|
<i class="mdi mdi-replay"></i> Run Again
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -58,7 +68,7 @@
|
|||||||
{% for method, stats in report.result.data.items %}
|
{% for method, stats in report.result.data.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="method">
|
<td colspan="4" class="method">
|
||||||
{{ method }}
|
<span class="ps-3">{{ method }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-nowrap report-stats">
|
<td class="text-end text-nowrap report-stats">
|
||||||
<span class="badge bg-success">{{ stats.success }}</span>
|
<span class="badge bg-success">{{ stats.success }}</span>
|
||||||
@ -83,28 +93,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-3">
|
{% endblock content-wrapper %}
|
||||||
{% if reports %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
{% for module, module_reports in reports %}
|
|
||||||
<h5>{{ module|bettertitle }}</h5>
|
|
||||||
<div class="small mb-2">
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
{% for report in module_reports %}
|
|
||||||
<a href="#{{ report.module }}.{{ report.class_name }}" class="list-group-item">
|
|
||||||
<i class="mdi mdi-file-chart-outline"></i> {{ report.name }}
|
|
||||||
<div class="float-end">
|
|
||||||
{% include 'extras/inc/job_label.html' with result=report.result %}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
@ -3,17 +3,29 @@
|
|||||||
|
|
||||||
{% block title %}Scripts{% endblock %}
|
{% block title %}Scripts{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block tabs %}
|
||||||
<div class="row">
|
<ul class="nav nav-tabs px-3">
|
||||||
<div class="col col-md-9">
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link active" role="tab">Scripts</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock tabs %}
|
||||||
|
|
||||||
|
{% block content-wrapper %}
|
||||||
|
<div class="tab-content">
|
||||||
{% if scripts %}
|
{% if scripts %}
|
||||||
{% for module, module_scripts in scripts.items %}
|
{% for module, module_scripts in scripts.items %}
|
||||||
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
|
<div class="card">
|
||||||
|
<h5 class="card-header">
|
||||||
|
<a name="module.{{ module }}"></a>
|
||||||
|
<i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
|
||||||
|
</h5>
|
||||||
|
<div class="card-body">
|
||||||
<table class="table table-hover table-headings reports">
|
<table class="table table-hover table-headings reports">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th width="250">Name</th>
|
||||||
<th>Status</th>
|
<th width="110">Status</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th class="text-end">Last Run</th>
|
<th class="text-end">Last Run</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -27,7 +39,9 @@
|
|||||||
<td>
|
<td>
|
||||||
{% include 'extras/inc/job_label.html' with result=script.result %}
|
{% include 'extras/inc/job_label.html' with result=script.result %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ script.Meta.description|render_markdown }}</td>
|
<td>
|
||||||
|
{{ script.Meta.description|render_markdown|placeholder }}
|
||||||
|
</td>
|
||||||
{% if script.result %}
|
{% if script.result %}
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
|
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
|
||||||
@ -39,6 +53,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
@ -49,28 +65,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-3">
|
{% endblock content-wrapper %}
|
||||||
{% if scripts %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
{% for module, module_scripts in scripts.items %}
|
|
||||||
<h5>{{ module|bettertitle }}</h5>
|
|
||||||
<div class="small mb-2">
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
{% for class_name, script in module_scripts.items %}
|
|
||||||
<a href="#script.{{ class_name }}" class="list-group-item">
|
|
||||||
<i class="mdi mdi-file-chart-outline"></i> {{ script.name }}
|
|
||||||
<div class="float-end">
|
|
||||||
{% include 'extras/inc/job_label.html' with result=script.result %}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
{% block breadcrumbs %}
|
{% block breadcrumbs %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
@ -69,7 +70,7 @@
|
|||||||
VLANs
|
VLANs
|
||||||
</h5>
|
</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% include 'inc/table.html' with table=vlans_table %}
|
{% render_table vlans_table 'inc/table.html' %}
|
||||||
</div>
|
</div>
|
||||||
{% if perms.ipam.add_vlan %}
|
{% if perms.ipam.add_vlan %}
|
||||||
<div class="card-footer text-end noprint">
|
<div class="card-footer text-end noprint">
|
||||||
|
@ -99,8 +99,20 @@ class TokenFilterSet(BaseFilterSet):
|
|||||||
model = Token
|
model = Token
|
||||||
fields = ['id', 'key', 'write_enabled']
|
fields = ['id', 'key', 'write_enabled']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(user__username__icontains=value) |
|
||||||
|
Q(description__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ObjectPermissionFilterSet(BaseFilterSet):
|
class ObjectPermissionFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='users',
|
field_name='users',
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
@ -127,3 +139,11 @@ class ObjectPermissionFilterSet(BaseFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ObjectPermission
|
model = ObjectPermission
|
||||||
fields = ['id', 'name', 'enabled', 'object_types']
|
fields = ['id', 'name', 'enabled', 'object_types']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(description__icontains=value)
|
||||||
|
)
|
||||||
|
16
netbox/utilities/markdown.py
Normal file
16
netbox/utilities/markdown.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import markdown
|
||||||
|
from markdown.inlinepatterns import SimpleTagPattern
|
||||||
|
|
||||||
|
STRIKE_RE = r'(~{2})(.+?)(~{2})'
|
||||||
|
|
||||||
|
|
||||||
|
class StrikethroughExtension(markdown.Extension):
|
||||||
|
"""
|
||||||
|
A python-markdown extension which support strikethrough formatting (e.g. "~~text~~").
|
||||||
|
"""
|
||||||
|
def extendMarkdown(self, md):
|
||||||
|
md.inlinePatterns.register(
|
||||||
|
markdown.inlinepatterns.SimpleTagPattern(STRIKE_RE, 'del'),
|
||||||
|
'strikethrough',
|
||||||
|
200
|
||||||
|
)
|
@ -17,6 +17,7 @@ from markdown import markdown
|
|||||||
|
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from utilities.forms import get_selected_values, TableConfigForm
|
from utilities.forms import get_selected_values, TableConfigForm
|
||||||
|
from utilities.markdown import StrikethroughExtension
|
||||||
from utilities.utils import foreground_color
|
from utilities.utils import foreground_color
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
@ -56,7 +57,7 @@ def render_markdown(value):
|
|||||||
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
|
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
|
||||||
|
|
||||||
# Render Markdown
|
# Render Markdown
|
||||||
html = markdown(value, extensions=['fenced_code', 'tables'])
|
html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
|
||||||
|
|
||||||
return mark_safe(html)
|
return mark_safe(html)
|
||||||
|
|
||||||
|
@ -146,6 +146,12 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf
|
|||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
label='Cluster (ID)',
|
label='Cluster (ID)',
|
||||||
)
|
)
|
||||||
|
cluster = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='cluster__name',
|
||||||
|
queryset=Cluster.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
label='Cluster',
|
||||||
|
)
|
||||||
region_id = TreeNodeMultipleChoiceFilter(
|
region_id = TreeNodeMultipleChoiceFilter(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
field_name='cluster__site__region',
|
field_name='cluster__site__region',
|
||||||
|
@ -324,9 +324,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
clusters = Cluster.objects.all()[:2]
|
clusters = Cluster.objects.all()[:2]
|
||||||
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
|
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
# TODO: 'cluster' should match on name
|
params = {'cluster': [clusters[0].name, clusters[1].name]}
|
||||||
# params = {'cluster': [clusters[0].name, clusters[1].name]}
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
|
||||||
|
|
||||||
def test_region(self):
|
def test_region(self):
|
||||||
regions = Region.objects.all()[:2]
|
regions = Region.objects.all()[:2]
|
||||||
|
Loading…
Reference in New Issue
Block a user