1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into feature

This commit is contained in:
jeremystretch
2022-05-31 15:50:23 -04:00
29 changed files with 311 additions and 181 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.2.3 placeholder: v3.2.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.2.3 placeholder: v3.2.4
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -8,7 +8,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v4 - uses: actions/stale@v5
with: with:
close-issue-message: > close-issue-message: >
This issue has been automatically closed due to lack of activity. In an This issue has been automatically closed due to lack of activity. In an

View File

@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [
--- ---
## CSRF_COOKIE_NAME
Default: `csrftoken`
The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail.
---
## CSRF_TRUSTED_ORIGINS ## CSRF_TRUSTED_ORIGINS
Default: `[]` Default: `[]`

View File

@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl
::: netbox.tables.TemplateColumn ::: netbox.tables.TemplateColumn
selection: selection:
members: false members:
- __init__

View File

@ -1,6 +1,33 @@
# NetBox v3.2 # NetBox v3.2
## v3.2.4 (FUTURE) ## v3.2.5 (FUTURE)
---
## v3.2.4 (2022-05-31)
### Enhancements
* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated
* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view
* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports
* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment
* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter
* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search
* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device
* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn
### Bug Fixes
* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters
* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields
* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view
* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed
* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis
* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list
* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance
* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
--- ---

View File

@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)), ('ASN', ('asn',)),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
('Attributes', ('type_id', 'status', 'commit_rate')), ('Attributes', ('type_id', 'status', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
type_id = DynamicModelMultipleChoiceField( type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),

View File

@ -354,6 +354,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other # Other
TYPE_HARDWIRED = 'hardwired' TYPE_HARDWIRED = 'hardwired'
TYPE_OTHER = 'other'
CHOICES = ( CHOICES = (
('IEC 60320', ( ('IEC 60320', (
@ -471,6 +472,7 @@ class PowerPortTypeChoices(ChoiceSet):
)), )),
('Other', ( ('Other', (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'),
)), )),
) )
@ -580,6 +582,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower'
# Other # Other
TYPE_HARDWIRED = 'hardwired' TYPE_HARDWIRED = 'hardwired'
TYPE_OTHER = 'other'
CHOICES = ( CHOICES = (
('IEC 60320', ( ('IEC 60320', (
@ -690,6 +693,7 @@ class PowerOutletTypeChoices(ChoiceSet):
)), )),
('Other', ( ('Other', (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'),
)), )),
) )
@ -1047,6 +1051,7 @@ class PortTypeChoices(ChoiceSet):
TYPE_URM_P2 = 'urm-p2' TYPE_URM_P2 = 'urm-p2'
TYPE_URM_P4 = 'urm-p4' TYPE_URM_P4 = 'urm-p4'
TYPE_URM_P8 = 'urm-p8' TYPE_URM_P8 = 'urm-p8'
TYPE_OTHER = 'other'
CHOICES = ( CHOICES = (
( (
@ -1099,6 +1104,12 @@ class PortTypeChoices(ChoiceSet):
(TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P4, 'URM-P4'),
(TYPE_URM_P8, 'URM-P8'), (TYPE_URM_P8, 'URM-P8'),
(TYPE_SPLICE, 'Splice'), (TYPE_SPLICE, 'Splice'),
),
),
(
'Other',
(
(TYPE_OTHER, 'Other'),
) )
) )
) )

View File

@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region model = Region
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'parent_id')), (None, ('q', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup model = SiteGroup
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'parent_id')), (None, ('q', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
status = MultipleChoiceField( status = MultipleChoiceField(
choices=SiteStatusChoices, choices=SiteStatusChoices,
@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -214,7 +214,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Function', ('status', 'role_id')), ('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -329,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer model = Manufacturer
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Contacts', ('contact', 'contact_role')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -518,7 +518,7 @@ class DeviceFilterForm(
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)), )),
@ -788,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -1102,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem model = InventoryItem
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
) )
role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(

View File

@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm):
] ]
def clean(self): def clean(self):
super().clean()
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({ raise forms.ValidationError({
'initial_position': "A position must be specified for the first VC member." 'initial_position': "A position must be specified for the first VC member."

View File

@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel):
return f'{self.name} ({self.asset_tag})' return f'{self.name} ({self.asset_tag})'
elif self.name: elif self.name:
return self.name return self.name
elif self.virtual_chassis and self.asset_tag:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
elif self.virtual_chassis: elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})' return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
elif self.device_type and self.asset_tag:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
elif self.device_type: elif self.device_type:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__() return super().__str__()

View File

@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm):
choices=CustomFieldTypeChoices, choices=CustomFieldTypeChoices,
help_text='Field data type (e.g. text, integer, etc.)' help_text='Field data type (e.g. text, integer, etc.)'
) )
object_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False,
help_text="Object type (for object or multi-object fields)"
)
choices = SimpleArrayField( choices = SimpleArrayField(
base_field=forms.CharField(), base_field=forms.CharField(),
required=False, required=False,
@ -36,9 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
class Meta: class Meta:
model = CustomField model = CustomField
fields = ( fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
'ui_visibility', 'validation_regex', 'ui_visibility',
) )

View File

@ -306,9 +306,16 @@ class BaseScript:
@classmethod @classmethod
def _get_vars(cls): def _get_vars(cls):
vars = {} vars = {}
for name, attr in cls.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable): # Iterate all base classes looking for ScriptVariables
vars[name] = attr for base_class in inspect.getmro(cls):
# When object is reached there's no reason to continue
if base_class is object:
break
for name, attr in base_class.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
# Order variables according to field_order # Order variables according to field_order
field_order = getattr(cls.Meta, 'field_order', None) field_order = getattr(cls.Meta, 'field_order', None)

View File

@ -40,10 +40,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3},read-write', 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,100,exact,,1,100,,read-write', 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write',
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,,read-write', 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write',
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {

View File

@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
if not value.strip(): if not value.strip():
return queryset return queryset
qs_filter = Q(description__icontains=value) qs_filter = Q(description__icontains=value)
qs_filter |= Q(prefix__contains=value.strip())
try: try:
prefix = str(netaddr.IPNetwork(value.strip()).cidr) prefix = str(netaddr.IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix) qs_filter |= Q(prefix__net_contains_or_equals=prefix)
qs_filter |= Q(prefix__contains=value.strip())
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
if not value.strip(): if not value.strip():
return queryset return queryset
qs_filter = Q(description__icontains=value) qs_filter = Q(description__icontains=value)
qs_filter |= Q(prefix__contains=value.strip())
try: try:
prefix = str(netaddr.IPNetwork(value.strip()).cidr) prefix = str(netaddr.IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix) qs_filter |= Q(prefix__net_contains_or_equals=prefix)
qs_filter |= Q(prefix__contains=value.strip())
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)

View File

@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
class PrefixTable(NetBoxTable): class PrefixTable(NetBoxTable):
prefix = tables.TemplateColumn( prefix = columns.TemplateColumn(
template_code=PREFIX_LINK, template_code=PREFIX_LINK,
export_raw=True,
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
prefix_flat = tables.TemplateColumn( prefix_flat = tables.TemplateColumn(

View File

@ -4,7 +4,7 @@ from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from circuits.models import Provider from circuits.models import Provider, Circuit
from circuits.tables import ProviderTable from circuits.tables import ProviderTable
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site from dcim.models import Interface, Site
@ -225,7 +225,9 @@ class ASNView(generic.ObjectView):
sites_table.configure(request) sites_table.configure(request)
# Gather assigned Providers # Gather assigned Providers
providers = instance.providers.restrict(request.user, 'view') providers = instance.providers.restrict(request.user, 'view').annotate(
count_circuits=count_related(Circuit, 'provider')
)
providers_table = ProviderTable(providers, user=request.user) providers_table = ProviderTable(providers, user=request.user)
providers_table.configure(request) providers_table.configure(request)
@ -674,11 +676,14 @@ class IPAddressView(generic.ObjectView):
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request) related_ips_table.configure(request)
services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance)
return { return {
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table, 'duplicate_ips_table': duplicate_ips_table,
'more_duplicate_ips': duplicate_ips.count() > 10, 'more_duplicate_ips': duplicate_ips.count() > 10,
'related_ips_table': related_ips_table, 'related_ips_table': related_ips_table,
'services': services,
} }

View File

@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300
# this setting is derived from the installed location. # this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
# The name to use for the csrf token cookie.
CSRF_COOKIE_NAME = 'csrftoken'
# The name to use for the session cookie. # The name to use for the session cookie.
SESSION_COOKIE_NAME = 'sessionid' SESSION_COOKIE_NAME = 'sessionid'

View File

@ -1,32 +1,24 @@
from collections import OrderedDict from collections import OrderedDict
from typing import Dict from typing import Dict
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet import circuits.filtersets
import circuits.tables
import dcim.filtersets
import dcim.tables
import ipam.filtersets
import ipam.tables
import tenancy.filtersets
import tenancy.tables
import virtualization.filtersets
import virtualization.tables
from circuits.models import Circuit, ProviderNetwork, Provider from circuits.models import Circuit, ProviderNetwork, Provider
from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
from dcim.filtersets import (
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet,
PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
)
from dcim.models import ( from dcim.models import (
Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
) )
from dcim.tables import ( from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable,
RackReservationTable, SiteTable, VirtualChassisTable,
)
from ipam.filtersets import (
AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet,
)
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
from tenancy.models import Contact, Tenant, ContactAssignment from tenancy.models import Contact, Tenant, ContactAssignment
from tenancy.tables import ContactTable, TenantTable
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineTable
SEARCH_MAX_RESULTS = 15 SEARCH_MAX_RESULTS = 15
@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict(
'queryset': Provider.objects.annotate( 'queryset': Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider') count_circuits=count_related(Circuit, 'provider')
), ),
'filterset': ProviderFilterSet, 'filterset': circuits.filtersets.ProviderFilterSet,
'table': ProviderTable, 'table': circuits.tables.ProviderTable,
'url': 'circuits:provider_list', 'url': 'circuits:provider_list',
}), }),
('circuit', { ('circuit', {
'queryset': Circuit.objects.prefetch_related( 'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site' 'type', 'provider', 'tenant', 'terminations__site'
), ),
'filterset': CircuitFilterSet, 'filterset': circuits.filtersets.CircuitFilterSet,
'table': CircuitTable, 'table': circuits.tables.CircuitTable,
'url': 'circuits:circuit_list', 'url': 'circuits:circuit_list',
}), }),
('providernetwork', { ('providernetwork', {
'queryset': ProviderNetwork.objects.prefetch_related('provider'), 'queryset': ProviderNetwork.objects.prefetch_related('provider'),
'filterset': ProviderNetworkFilterSet, 'filterset': circuits.filtersets.ProviderNetworkFilterSet,
'table': ProviderNetworkTable, 'table': circuits.tables.ProviderNetworkTable,
'url': 'circuits:providernetwork_list', 'url': 'circuits:providernetwork_list',
}), }),
) )
@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict(
( (
('site', { ('site', {
'queryset': Site.objects.prefetch_related('region', 'tenant'), 'queryset': Site.objects.prefetch_related('region', 'tenant'),
'filterset': SiteFilterSet, 'filterset': dcim.filtersets.SiteFilterSet,
'table': SiteTable, 'table': dcim.tables.SiteTable,
'url': 'dcim:site_list', 'url': 'dcim:site_list',
}), }),
('rack', { ('rack', {
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
device_count=count_related(Device, 'rack') device_count=count_related(Device, 'rack')
), ),
'filterset': RackFilterSet, 'filterset': dcim.filtersets.RackFilterSet,
'table': RackTable, 'table': dcim.tables.RackTable,
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',
}), }),
('rackreservation', { ('rackreservation', {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'filterset': RackReservationFilterSet, 'filterset': dcim.filtersets.RackReservationFilterSet,
'table': RackReservationTable, 'table': dcim.tables.RackReservationTable,
'url': 'dcim:rackreservation_list', 'url': 'dcim:rackreservation_list',
}), }),
('location', { ('location', {
@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict(
'rack_count', 'rack_count',
cumulative=True cumulative=True
).prefetch_related('site'), ).prefetch_related('site'),
'filterset': LocationFilterSet, 'filterset': dcim.filtersets.LocationFilterSet,
'table': LocationTable, 'table': dcim.tables.LocationTable,
'url': 'dcim:location_list', 'url': 'dcim:location_list',
}), }),
('devicetype', { ('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Device, 'device_type') instance_count=count_related(Device, 'device_type')
), ),
'filterset': DeviceTypeFilterSet, 'filterset': dcim.filtersets.DeviceTypeFilterSet,
'table': DeviceTypeTable, 'table': dcim.tables.DeviceTypeTable,
'url': 'dcim:devicetype_list', 'url': 'dcim:devicetype_list',
}), }),
('device', { ('device', {
'queryset': Device.objects.prefetch_related( 'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
), ),
'filterset': DeviceFilterSet, 'filterset': dcim.filtersets.DeviceFilterSet,
'table': DeviceTable, 'table': dcim.tables.DeviceTable,
'url': 'dcim:device_list', 'url': 'dcim:device_list',
}), }),
('moduletype', { ('moduletype', {
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Module, 'module_type') instance_count=count_related(Module, 'module_type')
), ),
'filterset': ModuleTypeFilterSet, 'filterset': dcim.filtersets.ModuleTypeFilterSet,
'table': ModuleTypeTable, 'table': dcim.tables.ModuleTypeTable,
'url': 'dcim:moduletype_list', 'url': 'dcim:moduletype_list',
}), }),
('module', { ('module', {
'queryset': Module.objects.prefetch_related( 'queryset': Module.objects.prefetch_related(
'module_type__manufacturer', 'device', 'module_bay', 'module_type__manufacturer', 'device', 'module_bay',
), ),
'filterset': ModuleFilterSet, 'filterset': dcim.filtersets.ModuleFilterSet,
'table': ModuleTable, 'table': dcim.tables.ModuleTable,
'url': 'dcim:module_list', 'url': 'dcim:module_list',
}), }),
('virtualchassis', { ('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate( 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=count_related(Device, 'virtual_chassis') member_count=count_related(Device, 'virtual_chassis')
), ),
'filterset': VirtualChassisFilterSet, 'filterset': dcim.filtersets.VirtualChassisFilterSet,
'table': VirtualChassisTable, 'table': dcim.tables.VirtualChassisTable,
'url': 'dcim:virtualchassis_list', 'url': 'dcim:virtualchassis_list',
}), }),
('cable', { ('cable', {
'queryset': Cable.objects.all(), 'queryset': Cable.objects.all(),
'filterset': CableFilterSet, 'filterset': dcim.filtersets.CableFilterSet,
'table': CableTable, 'table': dcim.tables.CableTable,
'url': 'dcim:cable_list', 'url': 'dcim:cable_list',
}), }),
('powerfeed', { ('powerfeed', {
'queryset': PowerFeed.objects.all(), 'queryset': PowerFeed.objects.all(),
'filterset': PowerFeedFilterSet, 'filterset': dcim.filtersets.PowerFeedFilterSet,
'table': PowerFeedTable, 'table': dcim.tables.PowerFeedTable,
'url': 'dcim:powerfeed_list', 'url': 'dcim:powerfeed_list',
}), }),
) )
@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict(
( (
('vrf', { ('vrf', {
'queryset': VRF.objects.prefetch_related('tenant'), 'queryset': VRF.objects.prefetch_related('tenant'),
'filterset': VRFFilterSet, 'filterset': ipam.filtersets.VRFFilterSet,
'table': VRFTable, 'table': ipam.tables.VRFTable,
'url': 'ipam:vrf_list', 'url': 'ipam:vrf_list',
}), }),
('aggregate', { ('aggregate', {
'queryset': Aggregate.objects.prefetch_related('rir'), 'queryset': Aggregate.objects.prefetch_related('rir'),
'filterset': AggregateFilterSet, 'filterset': ipam.filtersets.AggregateFilterSet,
'table': AggregateTable, 'table': ipam.tables.AggregateTable,
'url': 'ipam:aggregate_list', 'url': 'ipam:aggregate_list',
}), }),
('prefix', { ('prefix', {
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filterset': PrefixFilterSet, 'filterset': ipam.filtersets.PrefixFilterSet,
'table': PrefixTable, 'table': ipam.tables.PrefixTable,
'url': 'ipam:prefix_list', 'url': 'ipam:prefix_list',
}), }),
('ipaddress', { ('ipaddress', {
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
'filterset': IPAddressFilterSet, 'filterset': ipam.filtersets.IPAddressFilterSet,
'table': IPAddressTable, 'table': ipam.tables.IPAddressTable,
'url': 'ipam:ipaddress_list', 'url': 'ipam:ipaddress_list',
}), }),
('vlan', { ('vlan', {
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'filterset': VLANFilterSet, 'filterset': ipam.filtersets.VLANFilterSet,
'table': VLANTable, 'table': ipam.tables.VLANTable,
'url': 'ipam:vlan_list', 'url': 'ipam:vlan_list',
}), }),
('asn', { ('asn', {
'queryset': ASN.objects.prefetch_related('rir', 'tenant'), 'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
'filterset': ASNFilterSet, 'filterset': ipam.filtersets.ASNFilterSet,
'table': ASNTable, 'table': ipam.tables.ASNTable,
'url': 'ipam:asn_list', 'url': 'ipam:asn_list',
}), }),
('service', {
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
'filterset': ipam.filtersets.ServiceFilterSet,
'table': ipam.tables.ServiceTable,
'url': 'ipam:service_list',
}),
) )
) )
@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict(
( (
('tenant', { ('tenant', {
'queryset': Tenant.objects.prefetch_related('group'), 'queryset': Tenant.objects.prefetch_related('group'),
'filterset': TenantFilterSet, 'filterset': tenancy.filtersets.TenantFilterSet,
'table': TenantTable, 'table': tenancy.tables.TenantTable,
'url': 'tenancy:tenant_list', 'url': 'tenancy:tenant_list',
}), }),
('contact', { ('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
assignment_count=count_related(ContactAssignment, 'contact')), assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': ContactFilterSet, 'filterset': tenancy.filtersets.ContactFilterSet,
'table': ContactTable, 'table': tenancy.tables.ContactTable,
'url': 'tenancy:contact_list', 'url': 'tenancy:contact_list',
}), }),
) )
@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict(
device_count=count_related(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
), ),
'filterset': ClusterFilterSet, 'filterset': virtualization.filtersets.ClusterFilterSet,
'table': ClusterTable, 'table': virtualization.tables.ClusterTable,
'url': 'virtualization:cluster_list', 'url': 'virtualization:cluster_list',
}), }),
('virtualmachine', { ('virtualmachine', {
'queryset': VirtualMachine.objects.prefetch_related( 'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
), ),
'filterset': VirtualMachineFilterSet, 'filterset': virtualization.filtersets.VirtualMachineFilterSet,
'table': VirtualMachineTable, 'table': virtualization.tables.VirtualMachineTable,
'url': 'virtualization:virtualmachine_list', 'url': 'virtualization:virtualmachine_list',
}), }),
) )

View File

@ -84,6 +84,7 @@ if BASE_PATH:
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')

View File

@ -90,6 +90,15 @@ class TemplateColumn(tables.TemplateColumn):
""" """
PLACEHOLDER = mark_safe('—') PLACEHOLDER = mark_safe('—')
def __init__(self, export_raw=False, **kwargs):
"""
Args:
export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
False)
"""
super().__init__(**kwargs)
self.export_raw = export_raw
def render(self, *args, **kwargs): def render(self, *args, **kwargs):
ret = super().render(*args, **kwargs) ret = super().render(*args, **kwargs)
if not ret.strip(): if not ret.strip():
@ -97,6 +106,10 @@ class TemplateColumn(tables.TemplateColumn):
return ret return ret
def value(self, **kwargs): def value(self, **kwargs):
if self.export_raw:
# Skip template rendering and export raw value
return kwargs.get('value')
ret = super().value(**kwargs) ret = super().value(**kwargs)
if ret == self.PLACEHOLDER: if ret == self.PLACEHOLDER:
return '' return ''
@ -192,32 +205,35 @@ class ActionsColumn(tables.Column):
model = table.Meta.model model = table.Meta.model
request = getattr(table, 'context', {}).get('request') request = getattr(table, 'context', {}).get('request')
url_appendix = f'?return_url={request.path}' if request else '' url_appendix = f'?return_url={request.path}' if request else ''
html = ''
# Compile actions menu
links = [] links = []
user = getattr(request, 'user', AnonymousUser()) user = getattr(request, 'user', AnonymousUser())
for action, attrs in self.actions.items(): for action, attrs in self.actions.items():
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
if attrs.permission is None or user.has_perm(permission): if attrs.permission is None or user.has_perm(permission):
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
links.append(f'<li><a class="dropdown-item" href="{url}{url_appendix}">' links.append(
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>') f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
if not links: )
return '' if links:
html += (
menu = f'<span class="dropdown">' \ f'<span class="dropdown">'
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \ f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">'
f'<i class="mdi mdi-wrench"></i></a>' \ f'<i class="mdi mdi-wrench"></i></a>'
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>' f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
)
# Render any extra buttons from template code # Render any extra buttons from template code
if self.extra_buttons: if self.extra_buttons:
template = Template(self.extra_buttons) template = Template(self.extra_buttons)
context = getattr(table, "context", Context()) context = getattr(table, "context", Context())
context.update({'record': record}) context.update({'record': record})
menu = template.render(context) + menu html = template.render(context) + html
return mark_safe(menu) return mark_safe(html)
class ChoiceFieldColumn(tables.Column): class ChoiceFieldColumn(tables.Column):

View File

@ -15,74 +15,70 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-4"> <div class="col col-md-4">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Virtual Chassis</h5>
Virtual Chassis <div class="card-body">
</h5> <table class="table table-hover attr-table">
<div class="card-body"> <tr>
<table class="table table-hover attr-table"> <th scope="row">Domain</th>
<tr> <td>{{ object.domain|placeholder }}</td>
<th scope="row">Domain</th> </tr>
<td>{{ object.domain|placeholder }}</td> <tr>
</tr> <th scope="row">Master</th>
<tr> <td>{{ object.master|linkify }}</td>
<th scope="row">Master</th> </tr>
<td>{{ object.master|linkify }}</td> </table>
</tr> </div>
</table> </div>
</div> {% include 'inc/panels/custom_fields.html' %}
</div> {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %}
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div> </div>
<div class="col col-md-8"> <div class="col col-md-8">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Members</h5>
Members <div class="card-body">
</h5> <table class="table table-hover attr-table">
<div class="card-body"> <tr>
<table class="table table-hover attr-table"> <th>Device</th>
<tr> <th>Position</th>
<th>Device</th> <th>Master</th>
<th>Position</th> <th>Priority</th>
<th>Master</th> </tr>
<th>Priority</th> {% for vc_member in members %}
</tr> <tr{% if vc_member == device %} class="info"{% endif %}>
{% for vc_member in members %} <td>
<tr{% if vc_member == device %} class="info"{% endif %}> {{ vc_member|linkify }}
<td> </td>
{{ vc_member|linkify }} <td>
</td> {% badge vc_member.vc_position show_empty=True %}
<td> </td>
{% badge vc_member.vc_position show_empty=True %} <td>
</td> {% if object.master == vc_member %}
<td> {% checkmark True %}
{% if object.master == vc_member %} {% endif %}
{% checkmark True %} </td>
{% endif %} <td>
</td> {{ vc_member.vc_priority|placeholder }}
<td> </td>
{{ vc_member.vc_priority|placeholder }} </tr>
</td> {% endfor %}
</tr> </table>
{% endfor %}
</table>
</div>
{% if perms.dcim.change_virtualchassis %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:virtualchassis_add_member' pk=object.pk %}?site={{ object.master.site.pk }}&rack={{ object.master.rack.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Member
</a>
</div>
{% endif %}
</div> </div>
{% plugin_right_page object %} {% if perms.dcim.change_virtualchassis %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:virtualchassis_add_member' pk=object.pk %}?site={{ object.master.site.pk }}&rack={{ object.master.rack.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Member
</a>
</div>
{% endif %}
</div>
{% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -134,6 +134,24 @@
<div class="my-3"> <div class="my-3">
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %} {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
</div> </div>
<div class="card">
<h5 class="card-header">
Services
</h5>
<div class="card-body">
{% if services %}
<table class="table table-hover">
{% for service in services %}
{% include 'ipam/inc/service.html' %}
{% endfor %}
</table>
{% else %}
<div class="text-muted">
None
</div>
{% endif %}
</div>
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet):
queryset=ContactRole.objects.all(), queryset=ContactRole.objects.all(),
label='Contact Role' label='Contact Role'
) )
contact_group = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
field_name='contacts__contact__group',
lookup_expr='in',
label='Contact group',
)
# #

View File

@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Tenant model = Tenant
fieldsets = ( fieldsets = (
(None, ('q', 'tag', 'group_id')), (None, ('q', 'tag', 'group_id')),
('Contacts', ('contact', 'contact_role')) ('Contacts', ('contact', 'contact_role', 'contact_group'))
) )
group_id = DynamicModelMultipleChoiceField( group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),

View File

@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form):
required=False, required=False,
label=_('Contact Role') label=_('Contact Role')
) )
contact_group = DynamicModelMultipleChoiceField(
queryset=ContactGroup.objects.all(),
required=False,
label=_('Contact Group')
)

View File

@ -88,7 +88,12 @@ class DynamicModelChoiceMixin:
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
# will be populated on-demand via the APISelect widget. # will be populated on-demand via the APISelect widget.
data = bound_field.value() data = bound_field.value()
if data: if data:
# When the field is multiple choice pass the data as a list if it's not already
if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
data = [data]
field_name = getattr(self, 'to_field_name') or 'pk' field_name = getattr(self, 'to_field_name') or 'pk'
filter = self.filter(field_name=field_name) filter = self.filter(field_name=field_name)
try: try:
@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
widget = widgets.APISelectMultiple widget = widgets.APISelectMultiple
def clean(self, value): def clean(self, value):
""" value = value or []
When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
""" # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
return [None, *value] return [None, *value]
return super().clean(value) return super().clean(value)

View File

@ -29,6 +29,10 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ClusterGroup model = ClusterGroup
tag = TagFilterField(model) tag = TagFilterField(model)
fieldsets = (
(None, ('q', 'tag')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
)
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
@ -38,7 +42,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
('Attributes', ('group_id', 'type_id', 'status')), ('Attributes', ('group_id', 'type_id', 'status')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
type_id = DynamicModelMultipleChoiceField( type_id = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(), queryset=ClusterType.objects.all(),
@ -91,7 +95,7 @@ class VirtualMachineFilterForm(
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
) )
cluster_group_id = DynamicModelMultipleChoiceField( cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.all(),

View File

@ -18,10 +18,10 @@ gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==8.2.14 mkdocs-material==8.2.16
mkdocstrings[python-legacy]==0.18.1 mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.1.0 Pillow==9.1.1
psycopg2-binary==2.9.3 psycopg2-binary==2.9.3
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.5.12 sentry-sdk==1.5.12