Przeglądaj źródła

Merge branch 'develop' into feature

jeremystretch 3 lat temu
rodzic
commit
32322e95b6

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -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

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -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

+ 1 - 1
.github/workflows/stale.yml

@@ -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

+ 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
 ## CSRF_TRUSTED_ORIGINS
 
 
 Default: `[]`
 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
 ::: netbox.tables.TemplateColumn
     selection:
     selection:
-      members: false
+      members:
+        - __init__

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

@@ -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
 
 
 ---
 ---
 
 

+ 2 - 2
netbox/circuits/forms/filtersets.py

@@ -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(),

+ 11 - 0
netbox/dcim/choices.py

@@ -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'),
             )
             )
         )
         )
     )
     )

+ 9 - 9
netbox/dcim/forms/filtersets.py

@@ -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(

+ 2 - 0
netbox/dcim/forms/object_create.py

@@ -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."

+ 4 - 0
netbox/dcim/models/devices.py

@@ -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__()

+ 9 - 3
netbox/extras/forms/bulk_import.py

@@ -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',
-            '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',
         )
         )
 
 
 
 

+ 10 - 3
netbox/extras/scripts.py

@@ -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):
-                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
         # Order variables according to field_order
         field_order = getattr(cls.Meta, 'field_order', None)
         field_order = getattr(cls.Meta, 'field_order', None)

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

@@ -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',
-            '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 = {
         cls.bulk_edit_data = {

+ 4 - 0
netbox/ipam/filtersets.py

@@ -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)

+ 2 - 1
netbox/ipam/tables/ip.py

@@ -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(

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

+ 3 - 0
netbox/netbox/configuration_example.py

@@ -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'
 
 

+ 65 - 67
netbox/netbox/constants.py

@@ -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 (
-    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.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,
-            'table': ProviderTable,
+            'filterset': circuits.filtersets.ProviderFilterSet,
+            '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,
-            'table': CircuitTable,
+            'filterset': circuits.filtersets.CircuitFilterSet,
+            '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,
-            'table': ProviderNetworkTable,
+            'filterset': circuits.filtersets.ProviderNetworkFilterSet,
+            '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,
-            'table': SiteTable,
+            'filterset': dcim.filtersets.SiteFilterSet,
+            '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,
-            'table': RackTable,
+            'filterset': dcim.filtersets.RackFilterSet,
+            '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,
-            'table': RackReservationTable,
+            'filterset': dcim.filtersets.RackReservationFilterSet,
+            '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,
-            'table': LocationTable,
+            'filterset': dcim.filtersets.LocationFilterSet,
+            '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,
-            'table': DeviceTypeTable,
+            'filterset': dcim.filtersets.DeviceTypeFilterSet,
+            '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,
-            'table': DeviceTable,
+            'filterset': dcim.filtersets.DeviceFilterSet,
+            '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,
-            'table': ModuleTypeTable,
+            'filterset': dcim.filtersets.ModuleTypeFilterSet,
+            '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,
-            'table': ModuleTable,
+            'filterset': dcim.filtersets.ModuleFilterSet,
+            '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,
-            'table': VirtualChassisTable,
+            'filterset': dcim.filtersets.VirtualChassisFilterSet,
+            'table': dcim.tables.VirtualChassisTable,
             'url': 'dcim:virtualchassis_list',
             'url': 'dcim:virtualchassis_list',
         }),
         }),
         ('cable', {
         ('cable', {
             'queryset': Cable.objects.all(),
             'queryset': Cable.objects.all(),
-            'filterset': CableFilterSet,
-            'table': CableTable,
+            'filterset': dcim.filtersets.CableFilterSet,
+            'table': dcim.tables.CableTable,
             'url': 'dcim:cable_list',
             'url': 'dcim:cable_list',
         }),
         }),
         ('powerfeed', {
         ('powerfeed', {
             'queryset': PowerFeed.objects.all(),
             'queryset': PowerFeed.objects.all(),
-            'filterset': PowerFeedFilterSet,
-            'table': PowerFeedTable,
+            'filterset': dcim.filtersets.PowerFeedFilterSet,
+            '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,
-            'table': VRFTable,
+            'filterset': ipam.filtersets.VRFFilterSet,
+            '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,
-            'table': AggregateTable,
+            'filterset': ipam.filtersets.AggregateFilterSet,
+            '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,
-            'table': PrefixTable,
+            'filterset': ipam.filtersets.PrefixFilterSet,
+            '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,
-            'table': IPAddressTable,
+            'filterset': ipam.filtersets.IPAddressFilterSet,
+            '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,
-            'table': VLANTable,
+            'filterset': ipam.filtersets.VLANFilterSet,
+            '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,
-            'table': ASNTable,
+            'filterset': ipam.filtersets.ASNFilterSet,
+            '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,
-            'table': TenantTable,
+            'filterset': tenancy.filtersets.TenantFilterSet,
+            '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,
-            'table': ContactTable,
+            'filterset': tenancy.filtersets.ContactFilterSet,
+            '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,
-            'table': ClusterTable,
+            'filterset': virtualization.filtersets.ClusterFilterSet,
+            '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,
-            'table': VirtualMachineTable,
+            'filterset': virtualization.filtersets.VirtualMachineFilterSet,
+            'table': virtualization.tables.VirtualMachineTable,
             'url': 'virtualization:virtualmachine_list',
             'url': 'virtualization:virtualmachine_list',
         }),
         }),
     )
     )

+ 1 - 0
netbox/netbox/settings.py

@@ -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')

+ 28 - 12
netbox/netbox/tables/columns.py

@@ -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}">'
-                             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
         # 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):

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

@@ -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">
-            <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>
     <div class="col col-md-8">
     <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>
         </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">
-        {% plugin_full_width_page object %}
-    </div>
+  <div class="col col-md-12">
+    {% plugin_full_width_page object %}
+  </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -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>

+ 6 - 0
netbox/tenancy/filtersets.py

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

+ 1 - 1
netbox/tenancy/forms/filtersets.py

@@ -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(),

+ 5 - 0
netbox/tenancy/forms/forms.py

@@ -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')
+    )

+ 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
         # 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):
-        """
-        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:
         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)

+ 6 - 2
netbox/virtualization/forms/filtersets.py

@@ -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(),

+ 3 - 3
requirements.txt

@@ -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
-mkdocstrings[python-legacy]==0.18.1
+mkdocs-material==8.2.16
+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