Kaynağa Gözat

Merge pull request #9454 from netbox-community/develop

Release v3.2.4
Jeremy Stretch 3 yıl önce
ebeveyn
işleme
9d308e6246

+ 1 - 1
.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

+ 1 - 1
.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

+ 1 - 1
.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

+ 8 - 0
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: `[]`

+ 2 - 1
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__

+ 28 - 0
docs/release-notes/version-3.2.md

@@ -1,5 +1,33 @@
 # NetBox v3.2
 
+## 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
+
+
+---
+
 ## v3.2.3 (2022-05-12)
 
 ### Enhancements

+ 2 - 2
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(),

+ 11 - 0
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'),
             )
         )
     )

+ 9 - 9
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(

+ 2 - 0
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."

+ 4 - 0
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__()

+ 9 - 2
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,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm):
     class Meta:
         model = CustomField
         fields = (
-            'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
-            'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
+            'name', 'label', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
+            'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
+            'validation_regex',
         )
 
 

+ 10 - 3
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)

+ 5 - 4
netbox/extras/tests/test_views.py

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

+ 4 - 0
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)

+ 2 - 1
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(

+ 7 - 2
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,
         }
 
 

+ 3 - 0
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'
 

+ 65 - 67
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',
         }),
     )

+ 2 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.2.3'
+VERSION = '3.2.4'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -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')

+ 28 - 12
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'<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 ''
-
-        menu = f'<span class="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'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
+                links.append(
+                    f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
+                    f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
+                )
+        if links:
+            html += (
+                f'<span class="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'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
+            )
 
         # 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):

+ 59 - 63
netbox/templates/dcim/virtualchassis.html

@@ -15,74 +15,70 @@
 {% block content %}
 <div class="row">
 	<div class="col col-md-4">
-        <div class="card">
-            <h5 class="card-header">
-                Virtual Chassis
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">Domain</th>
-                        <td>{{ object.domain|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Master</th>
-                        <td>{{ object.master|linkify }}</td>
-                    </tr>
-                </table>
-            </div>
-        </div>
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' %}
-        {% plugin_left_page object %}
+    <div class="card">
+      <h5 class="card-header">Virtual Chassis</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Domain</th>
+            <td>{{ object.domain|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Master</th>
+            <td>{{ object.master|linkify }}</td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    {% include 'inc/panels/custom_fields.html' %}
+    {% include 'inc/panels/tags.html' %}
+    {% plugin_left_page object %}
     </div>
     <div class="col col-md-8">
-        <div class="card">
-            <h5 class="card-header">
-                Members
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th>Device</th>
-                        <th>Position</th>
-                        <th>Master</th>
-                        <th>Priority</th>
-                    </tr>
-                    {% for vc_member in members %}
-                        <tr{% if vc_member == device %} class="info"{% endif %}>
-                            <td>
-                                {{ vc_member|linkify }}
-                            </td>
-                            <td>
-                              {% badge vc_member.vc_position show_empty=True %}
-                            </td>
-                            <td>
-                              {% if object.master == vc_member %}
-                                {% checkmark True %}
-                              {% endif %}
-                            </td>
-                            <td>
-                              {{ vc_member.vc_priority|placeholder }}
-                            </td>
-                        </tr>
-                    {% 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 class="card">
+        <h5 class="card-header">Members</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th>Device</th>
+              <th>Position</th>
+              <th>Master</th>
+              <th>Priority</th>
+            </tr>
+            {% for vc_member in members %}
+              <tr{% if vc_member == device %} class="info"{% endif %}>
+                <td>
+                  {{ vc_member|linkify }}
+                </td>
+                <td>
+                  {% badge vc_member.vc_position show_empty=True %}
+                </td>
+                <td>
+                  {% if object.master == vc_member %}
+                    {% checkmark True %}
+                  {% endif %}
+                </td>
+                <td>
+                  {{ vc_member.vc_priority|placeholder }}
+                </td>
+              </tr>
+            {% endfor %}
+          </table>
         </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 class="row">
-    <div class="col col-md-12">
-        {% plugin_full_width_page object %}
-    </div>
+  <div class="col col-md-12">
+    {% plugin_full_width_page object %}
+  </div>
 </div>
 {% endblock %}

+ 18 - 0
netbox/templates/ipam/ipaddress.html

@@ -128,6 +128,24 @@
     <div class="my-3">
       {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
     </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 %}
 	</div>
 </div>

+ 6 - 0
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',
+    )
 
 
 #

+ 1 - 1
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(),

+ 5 - 0
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')
+    )

+ 10 - 4
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)

+ 6 - 2
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')),
         ('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(),
@@ -87,7 +91,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(),

+ 3 - 3
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