mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-18 13:38:16 -06:00
Merge branch 'feature' into 14132-event-refactor-2
This commit is contained in:
commit
7a50706605
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.4
|
||||
placeholder: v3.6.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.4
|
||||
placeholder: v3.6.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
---
|
||||
name: 🌍 Translation
|
||||
description: Request support for a new language in the user interface
|
||||
labels: ["type: translation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
**NOTE:** This template is used only for proposing the addition of *new* languages. Please do
|
||||
not use it to request changes to existing translations.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language
|
||||
description: What is the name of the language in English?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: ISO 639-1 code
|
||||
description: >
|
||||
What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
|
||||
assigned to the language?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Volunteer
|
||||
description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Comments
|
||||
description: Any other notes you would like to share
|
@ -53,7 +53,8 @@ django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
||||
django-taggit
|
||||
# TODO: Upgrade to v5.0 for NetBox v3.7 beta
|
||||
django-taggit<5.0
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
@ -125,10 +126,6 @@ PyYAML
|
||||
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||
requests
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
||||
sentry-sdk
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
social-auth-core
|
||||
|
@ -4,27 +4,15 @@
|
||||
|
||||
### Enabling Error Reporting
|
||||
|
||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
```
|
||||
|
||||
### Using a Custom DSN
|
||||
|
||||
If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
|
||||
|
||||
```
|
||||
https://examplePublicKey@o0.ingest.sentry.io/0
|
||||
```
|
||||
|
||||
Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
|
||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
```
|
||||
|
||||
Setting `SENTRY_ENABLED` to False will disable the Sentry integration.
|
||||
|
||||
### Assigning Tags
|
||||
|
||||
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
|
||||
|
@ -18,6 +18,9 @@ Default: False
|
||||
|
||||
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
|
||||
|
||||
!!! note
|
||||
The `sentry-sdk` Python package is required to enable Sentry integration.
|
||||
|
||||
---
|
||||
|
||||
## SENTRY_SAMPLE_RATE
|
||||
|
@ -17,6 +17,7 @@ class MyModelIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'device', 'status', 'description')
|
||||
```
|
||||
|
||||
A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.
|
||||
|
@ -227,6 +227,17 @@ sudo sh -c "echo 'boto3' >> /opt/netbox/local_requirements.txt"
|
||||
!!! info
|
||||
These packages were previously required in NetBox v3.5 but now are optional.
|
||||
|
||||
### Sentry Integration
|
||||
|
||||
NetBox may be configured to send error reports to [Sentry](../administration/error-reporting.md) for analysis. This integration requires installation of the `sentry-sdk` Python library.
|
||||
|
||||
```no-highlight
|
||||
sudo sh -c "echo 'sentry-sdk' >> /opt/netbox/local_requirements.txt"
|
||||
```
|
||||
|
||||
!!! info
|
||||
Sentry integration was previously included by default in NetBox v3.6 but is now optional.
|
||||
|
||||
## Run the Upgrade Script
|
||||
|
||||
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:
|
||||
|
@ -14,8 +14,11 @@ class MyModelIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'device', 'status', 'description')
|
||||
```
|
||||
|
||||
Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
|
||||
|
||||
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
|
||||
|
||||
```python
|
||||
|
@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
]
|
||||
},
|
||||
{
|
||||
"attr": "tags",
|
||||
"attr": "tags.slug",
|
||||
"value": "exempt",
|
||||
"op": "contains"
|
||||
}
|
||||
|
@ -1,6 +1,36 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.5 (FUTURE)
|
||||
## v3.6.6 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## v3.6.5 (2023-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
|
||||
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
|
||||
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
|
||||
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
|
||||
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
|
||||
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
|
||||
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
|
||||
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
|
||||
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
|
||||
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
|
||||
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
|
||||
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
|
||||
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
|
||||
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
|
||||
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
|
||||
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
|
||||
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
|
||||
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
|
||||
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
|
||||
|
||||
---
|
||||
|
||||
|
@ -10,6 +10,7 @@ class CircuitIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -22,6 +23,7 @@ class CircuitTerminationIndex(SearchIndex):
|
||||
('port_speed', 2000),
|
||||
('upstream_speed', 2000),
|
||||
)
|
||||
display_attrs = ('circuit', 'site', 'provider_network', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -32,6 +34,7 @@ class CircuitTypeIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -42,6 +45,7 @@ class ProviderIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
class ProviderAccountIndex(SearchIndex):
|
||||
@ -51,6 +55,7 @@ class ProviderAccountIndex(SearchIndex):
|
||||
('account', 200),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('provider', 'account', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -62,3 +67,4 @@ class ProviderNetworkIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('provider', 'service_id', 'description')
|
||||
|
@ -1,11 +1,20 @@
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Command to clear the entire cache."""
|
||||
help = 'Clears the cache.'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Fetch the current config revision from the cache
|
||||
config_version = cache.get('config_version')
|
||||
# Clear the cache
|
||||
cache.clear()
|
||||
self.stdout.write('Cache has been cleared.', ending="\n")
|
||||
if config_version:
|
||||
# Activate the current config revision
|
||||
ConfigRevision.objects.get(id=config_version).activate()
|
||||
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")
|
||||
|
@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('type', 'status', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
|
@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
@ -818,7 +819,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
ContactModelFilterSet,
|
||||
LocalConfigContextFilterSet,
|
||||
PrimaryIPFilterSet,
|
||||
):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -994,16 +1001,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_device_bays',
|
||||
label=_('Has device bays'),
|
||||
)
|
||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='oob_ip',
|
||||
queryset=IPAddress.objects.all(),
|
||||
@ -1070,7 +1067,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
queryset=Device.objects.all(),
|
||||
|
@ -443,7 +443,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
platform = DynamicModelChoiceField(
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
|
@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front port templates to be created ({frontport_count}) must match the selected "
|
||||
"number of rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front ports to be created ({frontport_count}) must match the selected number of "
|
||||
"rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
|
@ -180,6 +180,17 @@ class Cable(PrimaryModel):
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
|
||||
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
|
||||
|
||||
if (a_pks & b_pks):
|
||||
raise ValidationError(
|
||||
_("A and B terminations cannot connect to the same object.")
|
||||
)
|
||||
|
||||
# Run clean() on any new CableTerminations
|
||||
for termination in self.a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||
|
@ -10,6 +10,7 @@ class CableIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('type', 'status', 'tenant', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -21,6 +22,7 @@ class ConsolePortIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('speed', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -32,6 +34,7 @@ class ConsoleServerPortIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('speed', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -44,6 +47,9 @@ class DeviceIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = (
|
||||
'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description',
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -54,6 +60,7 @@ class DeviceBayIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -64,6 +71,7 @@ class DeviceRoleIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -75,6 +83,7 @@ class DeviceTypeIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('manufacturer', 'part_number', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -85,6 +94,7 @@ class FrontPortIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -99,6 +109,7 @@ class InterfaceIndex(SearchIndex):
|
||||
('mtu', 2000),
|
||||
('speed', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -112,6 +123,7 @@ class InventoryItemIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('part_id', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -122,6 +134,7 @@ class LocationIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('site', 'status', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -132,6 +145,7 @@ class ManufacturerIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -143,6 +157,7 @@ class ModuleIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -153,6 +168,7 @@ class ModuleBayIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'position', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -164,6 +180,7 @@ class ModuleTypeIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('manufacturer', 'model', 'part_number', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -174,6 +191,7 @@ class PlatformIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('manufacturer', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -184,6 +202,7 @@ class PowerFeedIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('power_panel', 'rack', 'status', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -194,6 +213,7 @@ class PowerOutletIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -204,6 +224,7 @@ class PowerPanelIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'location', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -216,6 +237,7 @@ class PowerPortIndex(SearchIndex):
|
||||
('maximum_draw', 2000),
|
||||
('allocated_draw', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -229,6 +251,7 @@ class RackIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -238,6 +261,7 @@ class RackReservationIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('rack', 'tenant', 'user', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -248,6 +272,7 @@ class RackRoleIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -258,6 +283,7 @@ class RearPortIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -268,6 +294,7 @@ class RegionIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('parent', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -282,6 +309,7 @@ class SiteIndex(SearchIndex):
|
||||
('shipping_address', 2000),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('region', 'group', 'status', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -292,6 +320,7 @@ class SiteGroupIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('parent', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -303,6 +332,7 @@ class VirtualChassisIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('master', 'domain', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -314,3 +344,4 @@ class VirtualDeviceContextIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('device', 'status', 'identifier', 'description')
|
||||
|
@ -466,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
maximum_draw = tables.Column(
|
||||
verbose_name=_('Maximum draw (W)')
|
||||
)
|
||||
allocated_draw = tables.Column(
|
||||
verbose_name=_('Allocated draw (W)')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:powerport_list'
|
||||
)
|
||||
@ -625,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
verbose_name=_('VRF'),
|
||||
linkify=True
|
||||
)
|
||||
inventory_items = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name=_('Inventory Items'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
@ -636,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@ -933,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
discovered = columns.BooleanColumn(
|
||||
verbose_name=_('Discovered'),
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Parent'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:inventoryitem_list'
|
||||
)
|
||||
@ -941,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.InventoryItem
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
|
@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Tenant')
|
||||
)
|
||||
site = tables.Column(
|
||||
accessor='rack__site',
|
||||
linkify=True,
|
||||
verbose_name=_('Site'),
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage',
|
||||
'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
|
||||
'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
@ -4712,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
addresses = (
|
||||
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
|
||||
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
|
||||
IPAddress(assigned_object=None, address='10.1.1.3/24'),
|
||||
IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
|
||||
IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
|
||||
IPAddress(assigned_object=None, address='2001:db8::3/64'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(addresses)
|
||||
|
||||
vdcs[0].primary_ip4 = addresses[0]
|
||||
vdcs[0].primary_ip6 = addresses[3]
|
||||
vdcs[0].save()
|
||||
vdcs[1].primary_ip4 = addresses[1]
|
||||
vdcs[1].primary_ip6 = addresses[4]
|
||||
vdcs[1].save()
|
||||
|
||||
def test_device(self):
|
||||
@ -4738,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'has_primary_ip': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_primary_ip4(self):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
@ -2960,6 +2960,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
|
||||
@register_model_view(InventoryItem, 'children')
|
||||
class InventoryItemChildrenView(generic.ObjectChildrenView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
child_model = InventoryItem
|
||||
table = tables.InventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('Children'),
|
||||
badge=lambda obj: obj.child_items.count(),
|
||||
permission='dcim.view_inventoryitem',
|
||||
hide_if_empty=True,
|
||||
weight=5000
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.child_items.restrict(request.user, 'view')
|
||||
|
||||
|
||||
#
|
||||
# Inventory item roles
|
||||
#
|
||||
|
@ -4,7 +4,10 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.search.utils import get_indexer
|
||||
from netbox.registry import registry
|
||||
from utilities.fields import RestrictedGenericForeignKey
|
||||
from utilities.utils import content_type_identifier
|
||||
from ..fields import CachedValueField
|
||||
|
||||
__all__ = (
|
||||
@ -58,3 +61,19 @@ class CachedValue(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||
|
||||
@property
|
||||
def display_attrs(self):
|
||||
"""
|
||||
Render any display attributes associated with this search result.
|
||||
"""
|
||||
indexer = get_indexer(self.object_type)
|
||||
attrs = {}
|
||||
for attr in indexer.display_attrs:
|
||||
name = self.object._meta.get_field(attr).verbose_name
|
||||
if value := getattr(self.object, attr):
|
||||
if display_func := getattr(self.object, f'get_{attr}_display', None):
|
||||
attrs[name] = display_func()
|
||||
else:
|
||||
attrs[name] = value
|
||||
return attrs
|
||||
|
@ -446,7 +446,7 @@ class ConfigContextTestCase(
|
||||
'platforms': [],
|
||||
'tenant_groups': [],
|
||||
'tenants': [],
|
||||
'device_types': [devicetype.id,],
|
||||
'device_types': [devicetype.id],
|
||||
'tags': [],
|
||||
'data': '{"foo": 123}',
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ __all__ = (
|
||||
'L2VPNFilterSet',
|
||||
'L2VPNTerminationFilterSet',
|
||||
'PrefixFilterSet',
|
||||
'PrimaryIPFilterSet',
|
||||
'RIRFilterSet',
|
||||
'RoleFilterSet',
|
||||
'RouteTargetFilterSet',
|
||||
@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='net_mask_length'
|
||||
lookup_expr='net_mask_length',
|
||||
label=_('Mask length')
|
||||
)
|
||||
mask_length__gte = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
)
|
||||
mask_length = django_filters.NumberFilter(
|
||||
method='filter_mask_length',
|
||||
label=_('Mask length'),
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length',
|
||||
label=_('Mask length')
|
||||
)
|
||||
mask_length__gte = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length__gte'
|
||||
)
|
||||
mask_length__lte = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length__lte'
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VRF.objects.all(),
|
||||
@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
def filter_mask_length(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(address__net_mask_length=value)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||
if vrf is None:
|
||||
@ -1227,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
class PrimaryIPFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
An inheritable FilterSet for models which support primary IP assignment.
|
||||
"""
|
||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
|
@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm):
|
||||
choices=ServiceProtocolChoices,
|
||||
help_text=_('IP protocol')
|
||||
)
|
||||
ipaddresses = CSVModelMultipleChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
required=False,
|
||||
to_field_name='address',
|
||||
help_text=_('IP Address'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags')
|
||||
fields = (
|
||||
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
|
||||
)
|
||||
|
||||
def clean_ipaddresses(self):
|
||||
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
|
||||
for ip_address in self.cleaned_data['ipaddresses']:
|
||||
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
|
||||
raise forms.ValidationError(
|
||||
_("{ip} is not assigned to this device/VM.").format(ip=ip_address)
|
||||
)
|
||||
|
||||
return self.cleaned_data['ipaddresses']
|
||||
|
||||
|
||||
class L2VPNImportForm(NetBoxModelImportForm):
|
||||
|
@ -523,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('protocol', 'port')),
|
||||
(_('Assignment'), ('device_id', 'virtual_machine_id')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device'),
|
||||
)
|
||||
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual Machine'),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
@ -11,6 +11,7 @@ class AggregateIndex(SearchIndex):
|
||||
('date_added', 2000),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('rir', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -20,6 +21,7 @@ class ASNIndex(SearchIndex):
|
||||
('asn', 100),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('rir', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -28,6 +30,7 @@ class ASNRangeIndex(SearchIndex):
|
||||
fields = (
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('rir', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -39,6 +42,7 @@ class FHRPGroupIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('protocol', 'auth_type', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -50,6 +54,7 @@ class IPAddressIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -61,6 +66,7 @@ class IPRangeIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -72,6 +78,7 @@ class L2VPNIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('type', 'identifier', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -82,6 +89,7 @@ class PrefixIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -92,6 +100,7 @@ class RIRIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -102,6 +111,7 @@ class RoleIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -112,6 +122,7 @@ class RouteTargetIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -122,6 +133,7 @@ class ServiceIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('device', 'virtual_machine', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -132,6 +144,7 @@ class ServiceTemplateIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -143,6 +156,7 @@ class VLANIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'group', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -154,6 +168,7 @@ class VLANGroupIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('max_vid', 2000),
|
||||
)
|
||||
display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -165,3 +180,4 @@ class VRFIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('rd', 'tenant', 'description')
|
||||
|
@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': ['24']}
|
||||
params = {'mask_length': [24]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'mask_length__gte': 32}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
params = {'mask_length__lte': 24}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
params = {'mask_length': [24]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
params = {'mask_length__gte': 64}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'mask_length__lte': 25}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
|
@ -4,6 +4,7 @@ from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from dcim.constants import InterfaceTypeChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
|
||||
from ipam.choices import *
|
||||
from ipam.models import *
|
||||
@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||
|
||||
services = (
|
||||
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
||||
@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
Service.objects.bulk_create(services)
|
||||
|
||||
ip_addresses = (
|
||||
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
|
||||
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name,protocol,ports,description",
|
||||
"Device 1,Service 1,tcp,1,First service",
|
||||
"Device 1,Service 2,tcp,2,Second service",
|
||||
"Device 1,Service 3,udp,3,Third service",
|
||||
"device,name,protocol,ports,ipaddresses,description",
|
||||
"Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
|
||||
"Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
|
||||
"Device 1,Service 3,udp,3,,Third service",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('ASNs'),
|
||||
badge=lambda x: x.get_child_asns().count(),
|
||||
permission='ipam.view_asns',
|
||||
permission='ipam.view_asn',
|
||||
weight=500
|
||||
)
|
||||
|
||||
|
@ -33,10 +33,12 @@ class SearchIndex:
|
||||
category: The label of the group under which this indexer is categorized (for form field display). If none,
|
||||
the name of the model's app will be used.
|
||||
fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each.
|
||||
display_attrs: An iterable of additional object attributes to include when displaying search results.
|
||||
"""
|
||||
model = None
|
||||
category = None
|
||||
fields = ()
|
||||
display_attrs = ()
|
||||
|
||||
@staticmethod
|
||||
def get_field_type(instance, field_name):
|
||||
|
@ -3,7 +3,8 @@ from collections import defaultdict
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import F, Window, Q
|
||||
from django.db.models import F, Window, Q, prefetch_related_objects
|
||||
from django.db.models.fields.related import ForeignKey
|
||||
from django.db.models.functions import window
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.utils.module_loading import import_string
|
||||
@ -13,7 +14,7 @@ from netaddr.core import AddrFormatError
|
||||
from extras.models import CachedValue, CustomField
|
||||
from netbox.registry import registry
|
||||
from utilities.querysets import RestrictedPrefetch
|
||||
from utilities.utils import title
|
||||
from utilities.utils import content_type_identifier, title
|
||||
from . import FieldTypes, LookupTypes, get_indexer
|
||||
|
||||
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
|
||||
@ -103,17 +104,17 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
|
||||
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||
|
||||
# Build the filter used to find relevant CachedValue records
|
||||
query_filter = Q(**{f'value__{lookup}': value})
|
||||
|
||||
if object_types:
|
||||
# Limit results by object type
|
||||
query_filter &= Q(object_type__in=object_types)
|
||||
|
||||
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
||||
# Partial string matches are valid only on string values
|
||||
# "Starts/ends with" matches are valid only on string values
|
||||
query_filter &= Q(type=FieldTypes.STRING)
|
||||
|
||||
if lookup == LookupTypes.PARTIAL:
|
||||
elif lookup == LookupTypes.PARTIAL:
|
||||
try:
|
||||
# If the value looks like an IP address, add an extra match for CIDR values
|
||||
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
||||
except (AddrFormatError, ValueError):
|
||||
@ -129,6 +130,12 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
)
|
||||
)[:MAX_RESULTS]
|
||||
|
||||
# Gather all ContentTypes present in the search results (used for prefetching related
|
||||
# objects). This must be done before generating the final results list, which returns
|
||||
# a RawQuerySet.
|
||||
content_type_ids = set(queryset.values_list('object_type', flat=True))
|
||||
content_types = ContentType.objects.filter(pk__in=content_type_ids)
|
||||
|
||||
# Construct a Prefetch to pre-fetch only those related objects for which the
|
||||
# user has permission to view.
|
||||
if user:
|
||||
@ -144,12 +151,34 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
params
|
||||
)
|
||||
|
||||
# Iterate through each ContentType represented in the search results and prefetch any
|
||||
# related objects necessary to render the prescribed display attributes (display_attrs).
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
indexer = registry['search'].get(content_type_identifier(ct))
|
||||
if not (display_attrs := getattr(indexer, 'display_attrs', None)):
|
||||
continue
|
||||
|
||||
# Add ForeignKey fields to prefetch list
|
||||
prefetch_fields = []
|
||||
for attr in display_attrs:
|
||||
field = model._meta.get_field(attr)
|
||||
if type(field) is ForeignKey:
|
||||
prefetch_fields.append(f'object__{attr}')
|
||||
|
||||
# Compile a list of all CachedValues referencing this object type, and prefetch
|
||||
# any related objects
|
||||
if prefetch_fields:
|
||||
objects = [r for r in results if r.object_type == ct]
|
||||
prefetch_related_objects(objects, *prefetch_fields)
|
||||
|
||||
# Omit any results pertaining to an object the user does not have permission to view
|
||||
ret = []
|
||||
for r in results:
|
||||
if r.object is not None:
|
||||
r.name = str(r.object)
|
||||
ret.append(r)
|
||||
|
||||
return ret
|
||||
|
||||
def cache(self, instances, indexer=None, remove_existing=True):
|
||||
|
14
netbox/netbox/search/utils.py
Normal file
14
netbox/netbox/search/utils.py
Normal file
@ -0,0 +1,14 @@
|
||||
from netbox.registry import registry
|
||||
from utilities.utils import content_type_identifier
|
||||
|
||||
__all__ = (
|
||||
'get_indexer',
|
||||
)
|
||||
|
||||
|
||||
def get_indexer(content_type):
|
||||
"""
|
||||
Return the registered search indexer for the given ContentType.
|
||||
"""
|
||||
ct_identifier = content_type_identifier(content_type)
|
||||
return registry['search'].get(ct_identifier)
|
@ -9,12 +9,14 @@ import warnings
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import django
|
||||
import sentry_sdk
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.utils.encoding import force_str
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
try:
|
||||
import sentry_sdk
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
from netbox.config import PARAMS
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
@ -25,7 +27,7 @@ from netbox.plugins import PluginConfig
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.5-dev'
|
||||
VERSION = '3.6.6-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -39,8 +41,6 @@ if sys.version_info < (3, 8):
|
||||
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
|
||||
)
|
||||
|
||||
DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485'
|
||||
|
||||
#
|
||||
# Configuration import
|
||||
#
|
||||
@ -161,7 +161,7 @@ RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
||||
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
|
||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
|
||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
|
||||
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
||||
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
|
||||
@ -506,6 +506,9 @@ AUTH_EXEMPT_PATHS = (
|
||||
MAINTENANCE_EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}admin/',
|
||||
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
|
||||
LOGIN_URL,
|
||||
LOGIN_REDIRECT_URL,
|
||||
LOGOUT_REDIRECT_URL
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
@ -518,12 +521,12 @@ SERIALIZATION_MODULES = {
|
||||
#
|
||||
|
||||
if SENTRY_ENABLED:
|
||||
try:
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
except ModuleNotFoundError:
|
||||
raise ImproperlyConfigured("SENTRY_ENABLED is True but the sentry-sdk package is not installed.")
|
||||
if not SENTRY_DSN:
|
||||
raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
|
||||
# If using the default DSN, force sampling rates
|
||||
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
|
||||
SENTRY_SAMPLE_RATE = 1.0
|
||||
SENTRY_TRACES_SAMPLE_RATE = 0
|
||||
# Initialize the SDK
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
@ -538,9 +541,6 @@ if SENTRY_ENABLED:
|
||||
# Assign any configured tags
|
||||
for k, v in SENTRY_TAGS.items():
|
||||
sentry_sdk.set_tag(k, v)
|
||||
# If using the default DSN, append a unique deployment ID tag for error correlation
|
||||
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
|
||||
sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID)
|
||||
|
||||
|
||||
#
|
||||
|
@ -15,6 +15,7 @@ from extras.choices import CustomFieldVisibilityChoices
|
||||
from netbox.tables import columns
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import get_viewname, highlight_string, title
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
'BaseTable',
|
||||
@ -119,7 +120,7 @@ class BaseTable(tables.Table):
|
||||
|
||||
@property
|
||||
def available_columns(self):
|
||||
return self._get_columns(visible=False)
|
||||
return sorted(self._get_columns(visible=False))
|
||||
|
||||
@property
|
||||
def selected_columns(self):
|
||||
@ -236,6 +237,10 @@ class SearchTable(tables.Table):
|
||||
value = tables.Column(
|
||||
verbose_name=_('Value'),
|
||||
)
|
||||
attrs = columns.TemplateColumn(
|
||||
template_code=SEARCH_RESULT_ATTRS,
|
||||
verbose_name=_('Attributes')
|
||||
)
|
||||
|
||||
trim_length = 30
|
||||
|
||||
|
18
netbox/netbox/tables/template_code.py
Normal file
18
netbox/netbox/tables/template_code.py
Normal file
@ -0,0 +1,18 @@
|
||||
SEARCH_RESULT_ATTRS = """
|
||||
{% for name, value in record.display_attrs.items %}
|
||||
<span class="badge bg-secondary"
|
||||
{% if value|length > 40 %} data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ value }}"{% endif %}
|
||||
>
|
||||
{{ name|bettertitle }}:
|
||||
{% with url=value.get_absolute_url %}
|
||||
{% if url %}<a href="url">{% endif %}
|
||||
{% if value|length > 40 %}
|
||||
{{ value|truncatechars:"40" }}
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
{% if url %}</a>{% endif %}
|
||||
{% endwith %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
"""
|
@ -9,7 +9,6 @@ from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||
from django.views.generic import View
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from netbox.plugins.utils import get_installed_plugins
|
||||
|
||||
@ -34,7 +33,9 @@ def handler_404(request, exception):
|
||||
"""
|
||||
Wrap Django's default 404 handler to enable Sentry reporting.
|
||||
"""
|
||||
capture_message("Page not found", level="error")
|
||||
if settings.SENTRY_ENABLED:
|
||||
from sentry_sdk import capture_message
|
||||
capture_message("Page not found", level="error")
|
||||
|
||||
return page_not_found(request, exception)
|
||||
|
||||
|
@ -25,4 +25,11 @@
|
||||
{% render_field form.priority %}
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -105,7 +105,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
|
||||
model = ContactAssignment
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
|
||||
'created', 'last_updated',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@extend_schema_field(OpenApiTypes.OBJECT)
|
||||
|
@ -3,11 +3,10 @@ from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filters import TagFilter
|
||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
|
||||
from .models import *
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ContactAssignmentFilterSet',
|
||||
'ContactFilterSet',
|
||||
@ -81,7 +80,7 @@ class ContactFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
||||
class ContactAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
|
@ -1,12 +1,9 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.forms.mixins import TagsMixin
|
||||
from extras.models import Tag
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import *
|
||||
from utilities.forms.mixins import BootstrapMixin
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
|
||||
|
||||
__all__ = (
|
||||
'ContactAssignmentForm',
|
||||
@ -122,7 +119,7 @@ class ContactForm(NetBoxModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm):
|
||||
class ContactAssignmentForm(NetBoxModelForm):
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Group'),
|
||||
queryset=ContactGroup.objects.all(),
|
||||
|
@ -1,6 +1,6 @@
|
||||
import graphene
|
||||
|
||||
from extras.graphql.mixins import TagsMixin
|
||||
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
|
||||
from tenancy import filtersets, models
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
|
||||
@ -69,7 +69,7 @@ class ContactGroupType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.ContactGroupFilterSet
|
||||
|
||||
|
||||
class ContactAssignmentType(TagsMixin, BaseObjectType):
|
||||
class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ContactAssignment
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.6 on 2023-11-06 20:23
|
||||
|
||||
from django.db import migrations, models
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0011_contactassignment_tags'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contactassignment',
|
||||
name='custom_field_data',
|
||||
field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
|
||||
),
|
||||
]
|
@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import TagsMixin
|
||||
from netbox.models.features import CustomFieldsMixin, TagsMixin
|
||||
from tenancy.choices import *
|
||||
|
||||
__all__ = (
|
||||
@ -109,7 +109,7 @@ class Contact(PrimaryModel):
|
||||
return reverse('tenancy:contact', args=[self.pk])
|
||||
|
||||
|
||||
class ContactAssignment(ChangeLoggedModel, TagsMixin):
|
||||
class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE
|
||||
|
@ -15,6 +15,7 @@ class ContactIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('group', 'title', 'phone', 'email', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -25,6 +26,7 @@ class ContactGroupIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -35,6 +37,7 @@ class ContactRoleIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -46,6 +49,7 @@ class TenantIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('group', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -56,3 +60,4 @@ class TenantGroupIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable):
|
||||
verbose_name=_('Role'),
|
||||
linkify=True
|
||||
)
|
||||
contact_group = tables.Column(
|
||||
accessor=Accessor('contact__group'),
|
||||
verbose_name=_('Group'),
|
||||
linkify=True
|
||||
)
|
||||
contact_title = tables.Column(
|
||||
accessor=Accessor('contact__title'),
|
||||
verbose_name=_('Contact Title')
|
||||
@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable):
|
||||
model = ContactAssignment
|
||||
fields = (
|
||||
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
|
||||
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions'
|
||||
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
|
||||
'actions'
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
|
||||
|
@ -387,6 +387,7 @@ class ContactAssignmentListView(generic.ObjectListView):
|
||||
filterset_form = forms.ContactAssignmentFilterForm
|
||||
table = tables.ContactAssignmentTable
|
||||
actions = {
|
||||
'import': {'add'},
|
||||
'export': {'view'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
|
@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()):
|
||||
)
|
||||
for header in HTTP_HEADERS:
|
||||
if header in request.META:
|
||||
client_ip = request.META[header].split(',')[0]
|
||||
client_ip = request.META[header].split(',')[0].partition(':')[0]
|
||||
try:
|
||||
return IPAddress(client_ip)
|
||||
except ValueError:
|
||||
|
@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
||||
@ -114,7 +115,8 @@ class VirtualMachineFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
ContactModelFilterSet,
|
||||
LocalConfigContextFilterSet
|
||||
LocalConfigContextFilterSet,
|
||||
PrimaryIPFilterSet,
|
||||
):
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=VirtualMachineStatusChoices,
|
||||
|
@ -204,7 +204,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
platform = DynamicModelChoiceField(
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
local_context_data = JSONField(
|
||||
required=False,
|
||||
|
@ -10,6 +10,7 @@ class ClusterIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('type', 'group', 'status', 'tenant', 'site', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -20,6 +21,7 @@ class ClusterGroupIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -30,6 +32,7 @@ class ClusterTypeIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -40,6 +43,7 @@ class VirtualMachineIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -51,3 +55,4 @@ class VMInterfaceIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('mtu', 2000),
|
||||
)
|
||||
display_attrs = ('virtual_machine', 'description')
|
||||
|
@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ipaddresses = (
|
||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
||||
IPAddress(address='192.0.2.3/24', assigned_object=None),
|
||||
IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
|
||||
IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
|
||||
IPAddress(address='2001:db8::3/64', assigned_object=None),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ipaddresses)
|
||||
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
|
||||
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
|
||||
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
|
||||
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
|
||||
@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_primary_ip4(self):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
|
||||
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = VMInterface.objects.all()
|
||||
|
@ -11,6 +11,7 @@ class WirelessLANIndex(SearchIndex):
|
||||
('auth_psk', 2000),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('group', 'status', 'vlan', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -21,6 +22,7 @@ class WirelessLANGroupIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -32,3 +34,4 @@ class WirelessLinkIndex(SearchIndex):
|
||||
('auth_psk', 2000),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('status', 'tenant', 'description')
|
||||
|
@ -1,5 +1,5 @@
|
||||
bleach==6.1.0
|
||||
Django==4.2.6
|
||||
Django==4.2.7
|
||||
django-cors-headers==4.3.0
|
||||
django-debug-toolbar==4.2.0
|
||||
django-filter==23.3
|
||||
@ -21,16 +21,15 @@ graphene-django==3.0.0
|
||||
gunicorn==21.2.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==9.4.6
|
||||
mkdocs-material==9.4.8
|
||||
mkdocstrings[python-legacy]==0.23.0
|
||||
netaddr==0.9.0
|
||||
Pillow==10.1.0
|
||||
psycopg[binary,pool]==3.1.12
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
sentry-sdk==1.32.0
|
||||
social-auth-app-django==5.4.0
|
||||
social-auth-core[openidconnect]==4.4.2
|
||||
social-auth-core[openidconnect]==4.5.0
|
||||
svgwrite==1.4.3
|
||||
tablib==3.5.0
|
||||
tzdata==2023.3
|
||||
|
Loading…
Reference in New Issue
Block a user