diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index df5ac6e81..a9af9c653 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.3 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 422b87f52..1fff99f1d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.3 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8099923f..7390ec1df 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,7 +8,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v4 + - uses: actions/stale@v5 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 76fd0a12c..670cf524b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -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 Default: `[]` diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index 77e258def..6dccb4ee2 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl ::: netbox.tables.TemplateColumn selection: - members: false + members: + - __init__ diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 408d572c7..ea5e580b8 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,33 @@ # 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 --- diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index ca3b003b9..46d3824bb 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ('Attributes', ('type_id', 'status', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index a89960457..2e96f9c67 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -354,6 +354,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -471,6 +472,7 @@ class PowerPortTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -580,6 +582,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -690,6 +693,7 @@ class PowerOutletTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -1047,6 +1051,7 @@ class PortTypeChoices(ChoiceSet): TYPE_URM_P2 = 'urm-p2' TYPE_URM_P4 = 'urm-p4' TYPE_URM_P8 = 'urm-p8' + TYPE_OTHER = 'other' CHOICES = ( ( @@ -1099,6 +1104,12 @@ class PortTypeChoices(ChoiceSet): (TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P8, 'URM-P8'), (TYPE_SPLICE, 'Splice'), + ), + ), + ( + 'Other', + ( + (TYPE_OTHER, 'Other'), ) ) ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0c7d02f9d..1535e5718 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte (None, ('q', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) status = MultipleChoiceField( choices=SiteStatusChoices, @@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF (None, ('q', 'tag')), ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -214,7 +214,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -329,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( (None, ('q', 'tag')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -518,7 +518,7 @@ class DeviceFilterForm( ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), @@ -788,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -1102,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( (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')), ) role_id = DynamicModelMultipleChoiceField( diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index e3e9c1179..8c9ddab19 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm): ] def clean(self): + super().clean() + if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: raise forms.ValidationError({ 'initial_position': "A position must be specified for the first VC member." diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8d50db958..e88af2d05 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel): return f'{self.name} ({self.asset_tag})' elif 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: 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: return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return super().__str__() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 95de7a2fe..d9148a5c3 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm): choices=CustomFieldTypeChoices, 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( base_field=forms.CharField(), required=False, @@ -36,9 +42,9 @@ class CustomFieldCSVForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', - 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', - 'ui_visibility', + 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', + 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', + 'validation_regex', 'ui_visibility', ) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4332d72f7..29fab5be8 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -306,9 +306,16 @@ class BaseScript: @classmethod def _get_vars(cls): vars = {} - for name, attr in cls.__dict__.items(): - if name not in vars and issubclass(attr.__class__, ScriptVariable): - vars[name] = attr + + # Iterate all base classes looking for ScriptVariables + 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 field_order = getattr(cls.Meta, 'field_order', None) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 0a9d85e15..936213cbf 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -40,10 +40,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,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', - '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', + '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', + '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', + 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write', ) cls.bulk_edit_data = { diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 7839dc03e..a445022ca 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 475ad787e..558631585 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(NetBoxTable): - prefix = tables.TemplateColumn( + prefix = columns.TemplateColumn( template_code=PREFIX_LINK, + export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) prefix_flat = tables.TemplateColumn( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 078848b3e..d89d6a711 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -4,7 +4,7 @@ from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from circuits.models import Provider +from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site @@ -225,7 +225,9 @@ class ASNView(generic.ObjectView): sites_table.configure(request) # 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.configure(request) @@ -674,11 +676,14 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) + services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance) + return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, 'more_duplicate_ips': duplicate_ips.count() > 10, 'related_ips_table': related_ips_table, + 'services': services, } diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index c82749e3f..ad0dcc7c3 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300 # this setting is derived from the installed location. # 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. SESSION_COOKIE_NAME = 'sessionid' diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e054dc9da..8ca0d98c1 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,32 +1,24 @@ from collections import OrderedDict 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.tables import CircuitTable, ProviderNetworkTable, ProviderTable -from dcim.filtersets import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet, - PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet, -) from dcim.models import ( Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, ) -from dcim.tables import ( - 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 ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF from tenancy.models import Contact, Tenant, ContactAssignment -from tenancy.tables import ContactTable, TenantTable from utilities.utils import count_related -from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine -from virtualization.tables import ClusterTable, VirtualMachineTable SEARCH_MAX_RESULTS = 15 @@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict( 'queryset': Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ), - 'filterset': ProviderFilterSet, - 'table': ProviderTable, + 'filterset': circuits.filtersets.ProviderFilterSet, + 'table': circuits.tables.ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' ), - 'filterset': CircuitFilterSet, - 'table': CircuitTable, + 'filterset': circuits.filtersets.CircuitFilterSet, + 'table': circuits.tables.CircuitTable, 'url': 'circuits:circuit_list', }), ('providernetwork', { 'queryset': ProviderNetwork.objects.prefetch_related('provider'), - 'filterset': ProviderNetworkFilterSet, - 'table': ProviderNetworkTable, + 'filterset': circuits.filtersets.ProviderNetworkFilterSet, + 'table': circuits.tables.ProviderNetworkTable, 'url': 'circuits:providernetwork_list', }), ) @@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict( ( ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), - 'filterset': SiteFilterSet, - 'table': SiteTable, + 'filterset': dcim.filtersets.SiteFilterSet, + 'table': dcim.tables.SiteTable, 'url': 'dcim:site_list', }), ('rack', { 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( device_count=count_related(Device, 'rack') ), - 'filterset': RackFilterSet, - 'table': RackTable, + 'filterset': dcim.filtersets.RackFilterSet, + 'table': dcim.tables.RackTable, 'url': 'dcim:rack_list', }), ('rackreservation', { 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), - 'filterset': RackReservationFilterSet, - 'table': RackReservationTable, + 'filterset': dcim.filtersets.RackReservationFilterSet, + 'table': dcim.tables.RackReservationTable, 'url': 'dcim:rackreservation_list', }), ('location', { @@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict( 'rack_count', cumulative=True ).prefetch_related('site'), - 'filterset': LocationFilterSet, - 'table': LocationTable, + 'filterset': dcim.filtersets.LocationFilterSet, + 'table': dcim.tables.LocationTable, 'url': 'dcim:location_list', }), ('devicetype', { 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ), - 'filterset': DeviceTypeFilterSet, - 'table': DeviceTypeTable, + 'filterset': dcim.filtersets.DeviceTypeFilterSet, + 'table': dcim.tables.DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { 'queryset': Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), - 'filterset': DeviceFilterSet, - 'table': DeviceTable, + 'filterset': dcim.filtersets.DeviceFilterSet, + 'table': dcim.tables.DeviceTable, 'url': 'dcim:device_list', }), ('moduletype', { 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Module, 'module_type') ), - 'filterset': ModuleTypeFilterSet, - 'table': ModuleTypeTable, + 'filterset': dcim.filtersets.ModuleTypeFilterSet, + 'table': dcim.tables.ModuleTypeTable, 'url': 'dcim:moduletype_list', }), ('module', { 'queryset': Module.objects.prefetch_related( 'module_type__manufacturer', 'device', 'module_bay', ), - 'filterset': ModuleFilterSet, - 'table': ModuleTable, + 'filterset': dcim.filtersets.ModuleFilterSet, + 'table': dcim.tables.ModuleTable, 'url': 'dcim:module_list', }), ('virtualchassis', { 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( member_count=count_related(Device, 'virtual_chassis') ), - 'filterset': VirtualChassisFilterSet, - 'table': VirtualChassisTable, + 'filterset': dcim.filtersets.VirtualChassisFilterSet, + 'table': dcim.tables.VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), ('cable', { 'queryset': Cable.objects.all(), - 'filterset': CableFilterSet, - 'table': CableTable, + 'filterset': dcim.filtersets.CableFilterSet, + 'table': dcim.tables.CableTable, 'url': 'dcim:cable_list', }), ('powerfeed', { 'queryset': PowerFeed.objects.all(), - 'filterset': PowerFeedFilterSet, - 'table': PowerFeedTable, + 'filterset': dcim.filtersets.PowerFeedFilterSet, + 'table': dcim.tables.PowerFeedTable, 'url': 'dcim:powerfeed_list', }), ) @@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict( ( ('vrf', { 'queryset': VRF.objects.prefetch_related('tenant'), - 'filterset': VRFFilterSet, - 'table': VRFTable, + 'filterset': ipam.filtersets.VRFFilterSet, + 'table': ipam.tables.VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { 'queryset': Aggregate.objects.prefetch_related('rir'), - 'filterset': AggregateFilterSet, - 'table': AggregateTable, + 'filterset': ipam.filtersets.AggregateFilterSet, + 'table': ipam.tables.AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), - 'filterset': PrefixFilterSet, - 'table': PrefixTable, + 'filterset': ipam.filtersets.PrefixFilterSet, + 'table': ipam.tables.PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), - 'filterset': IPAddressFilterSet, - 'table': IPAddressTable, + 'filterset': ipam.filtersets.IPAddressFilterSet, + 'table': ipam.tables.IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), - 'filterset': VLANFilterSet, - 'table': VLANTable, + 'filterset': ipam.filtersets.VLANFilterSet, + 'table': ipam.tables.VLANTable, 'url': 'ipam:vlan_list', }), ('asn', { 'queryset': ASN.objects.prefetch_related('rir', 'tenant'), - 'filterset': ASNFilterSet, - 'table': ASNTable, + 'filterset': ipam.filtersets.ASNFilterSet, + 'table': ipam.tables.ASNTable, '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', { 'queryset': Tenant.objects.prefetch_related('group'), - 'filterset': TenantFilterSet, - 'table': TenantTable, + 'filterset': tenancy.filtersets.TenantFilterSet, + 'table': tenancy.tables.TenantTable, 'url': 'tenancy:tenant_list', }), ('contact', { 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( assignment_count=count_related(ContactAssignment, 'contact')), - 'filterset': ContactFilterSet, - 'table': ContactTable, + 'filterset': tenancy.filtersets.ContactFilterSet, + 'table': tenancy.tables.ContactTable, 'url': 'tenancy:contact_list', }), ) @@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict( device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ), - 'filterset': ClusterFilterSet, - 'table': ClusterTable, + 'filterset': virtualization.filtersets.ClusterFilterSet, + 'table': virtualization.tables.ClusterTable, 'url': 'virtualization:cluster_list', }), ('virtualmachine', { 'queryset': VirtualMachine.objects.prefetch_related( 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', ), - 'filterset': VirtualMachineFilterSet, - 'table': VirtualMachineTable, + 'filterset': virtualization.filtersets.VirtualMachineFilterSet, + 'table': virtualization.tables.VirtualMachineTable, 'url': 'virtualization:virtualmachine_list', }), ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ff10d1096..fd3730e2c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -84,6 +84,7 @@ if BASE_PATH: CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_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', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 801b97766..e82e8a1ea 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -90,6 +90,15 @@ class TemplateColumn(tables.TemplateColumn): """ 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): ret = super().render(*args, **kwargs) if not ret.strip(): @@ -97,6 +106,10 @@ class TemplateColumn(tables.TemplateColumn): return ret def value(self, **kwargs): + if self.export_raw: + # Skip template rendering and export raw value + return kwargs.get('value') + ret = super().value(**kwargs) if ret == self.PLACEHOLDER: return '' @@ -192,32 +205,35 @@ class ActionsColumn(tables.Column): model = table.Meta.model request = getattr(table, 'context', {}).get('request') url_appendix = f'?return_url={request.path}' if request else '' + html = '' + # Compile actions menu links = [] user = getattr(request, 'user', AnonymousUser()) for action, attrs in self.actions.items(): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append(f'
  • ' - f' {attrs.title}
  • ') - - if not links: - return '' - - menu = f'' \ - f'' \ - f'' \ - f'' + links.append( + f'
  • ' + f' {attrs.title}
  • ' + ) + if links: + html += ( + f'' + f'' + f'' + f'' + ) # Render any extra buttons from template code if self.extra_buttons: template = Template(self.extra_buttons) context = getattr(table, "context", Context()) 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): diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 4683b775b..1ff9f2e9a 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -15,74 +15,70 @@ {% block content %}
    -
    -
    - Virtual Chassis -
    -
    - - - - - - - - - -
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    -
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    Virtual Chassis
    +
    + + + + + + + + + +
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    -
    - Members -
    -
    - - - - - - - - {% for vc_member in members %} - - - - - - - {% endfor %} -
    DevicePositionMasterPriority
    - {{ vc_member|linkify }} - - {% badge vc_member.vc_position show_empty=True %} - - {% if object.master == vc_member %} - {% checkmark True %} - {% endif %} - - {{ vc_member.vc_priority|placeholder }} -
    -
    - {% if perms.dcim.change_virtualchassis %} - - {% endif %} +
    +
    Members
    +
    + + + + + + + + {% for vc_member in members %} + + + + + + + {% endfor %} +
    DevicePositionMasterPriority
    + {{ vc_member|linkify }} + + {% badge vc_member.vc_position show_empty=True %} + + {% if object.master == vc_member %} + {% checkmark True %} + {% endif %} + + {{ vc_member.vc_priority|placeholder }} +
    - {% plugin_right_page object %} + {% if perms.dcim.change_virtualchassis %} + + {% endif %} +
    + {% plugin_right_page object %}
    -
    - {% plugin_full_width_page object %} -
    +
    + {% plugin_full_width_page object %} +
    {% endblock %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 96a76cf8c..7981ea0fe 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -134,6 +134,24 @@
    {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
    +
    +
    + Services +
    +
    + {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
    + {% else %} +
    + None +
    + {% endif %} +
    +
    {% plugin_right_page object %}
    diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 8ca4ae29c..dd14a412b 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet): queryset=ContactRole.objects.all(), label='Contact Role' ) + contact_group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='contacts__contact__group', + lookup_expr='in', + label='Contact group', + ) # diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 15d7773b7..02589d733 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( (None, ('q', 'tag', 'group_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 5dcad1d43..5e78bc540 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form): required=False, label=_('Contact Role') ) + contact_group = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Contact Group') + ) diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index f83fc6a7c..68e71610c 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -88,7 +88,12 @@ class DynamicModelChoiceMixin: # 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. data = bound_field.value() + 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' filter = self.filter(field_name=field_name) try: @@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip widget = widgets.APISelectMultiple def clean(self, value): - """ - 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. - """ + 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. 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] return [None, *value] + return super().clean(value) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index b3da87f7a..e15a76a43 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -29,6 +29,10 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm): class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) + fieldsets = ( + (None, ('q', 'tag')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), + ) class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): @@ -38,7 +42,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), @@ -91,7 +95,7 @@ class VirtualMachineFilterForm( ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), diff --git a/requirements.txt b/requirements.txt index 0a15fcf20..293a33542 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,10 +18,10 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.14 -mkdocstrings[python-legacy]==0.18.1 +mkdocs-material==8.2.16 +mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.1.0 +Pillow==9.1.1 psycopg2-binary==2.9.3 PyYAML==6.0 sentry-sdk==1.5.12