From ae7a47ca60a275ff8574da2f2b50cc055c1091e9 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 11:52:13 -0500 Subject: [PATCH 01/17] Adds comments field to abstract NestedGroupModel and associated migrations Models affected: - dcim: `Location`, `Region`, `SiteGroup` - tenancy`: `ContactGroup`, `TenantGroup` - wireless: `WirelessLANGroup` --- ...ents_region_comments_sitegroup_comments.py | 28 +++++++++++++++++++ netbox/netbox/models/__init__.py | 4 +++ ...tactgroup_comments_tenantgroup_comments.py | 23 +++++++++++++++ .../0014_wirelesslangroup_comments.py | 18 ++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py create mode 100644 netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py create mode 100644 netbox/wireless/migrations/0014_wirelesslangroup_comments.py diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py new file mode 100644 index 000000000..51031de53 --- /dev/null +++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.7 on 2025-03-10 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0201_add_power_outlet_status'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='region', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='sitegroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index b1f7cfd48..3ad0ac556 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -150,6 +150,10 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): max_length=200, blank=True ) + comments = models.TextField( + verbose_name=_('comments'), + blank=True + ) objects = TreeManager() diff --git a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py new file mode 100644 index 000000000..3481baeec --- /dev/null +++ b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-03-10 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0017_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='contactgroup', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='tenantgroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/wireless/migrations/0014_wirelesslangroup_comments.py b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py new file mode 100644 index 000000000..3e3cab270 --- /dev/null +++ b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-03-10 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0013_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslangroup', + name='comments', + field=models.TextField(blank=True), + ), + ] From 44efd5e833212e3e12c3f7ab75fc2d1cf3a4b314 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 14:53:23 -0500 Subject: [PATCH 02/17] Adds Location.comments field in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] dcim.forms.model_forms.LocationForm, create/edit (e.g. model_forms.py) - [x] dcim.forms.buld_edit.LocationBulkEditForm, bulk edit - [x] dcim.dorms.bulk_import.LocationImportForm, CSV import - [x] filter (UI and API) - [NA] UI - Note: could not find any comments related things in filtersets - [x] API - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/dcim/api/serializers_/sites.py | 2 +- netbox/dcim/filtersets.py | 3 ++- netbox/dcim/forms/bulk_edit.py | 3 ++- netbox/dcim/forms/bulk_import.py | 5 ++++- netbox/dcim/forms/model_forms.py | 4 +++- netbox/dcim/search.py | 1 + netbox/dcim/tables/sites.py | 2 +- netbox/dcim/tests/test_api.py | 5 +++++ netbox/dcim/tests/test_filtersets.py | 10 ++++++++++ netbox/dcim/tests/test_views.py | 21 +++++++++++++-------- 10 files changed, 42 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index b818cd954..1b95af70e 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', - 'prefix_count', '_depth', + 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e46730da8..bd7713289 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -280,7 +280,8 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM return queryset.filter( Q(name__icontains=value) | Q(facility__icontains=value) | - Q(description__icontains=value) + Q(description__icontains=value) | + Q(comments__icontains=value) ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 3b9a183cd..696474617 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -197,12 +197,13 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = Location fieldsets = ( FieldSet('site', 'parent', 'status', 'tenant', 'description'), ) - nullable_fields = ('parent', 'tenant', 'description') + nullable_fields = ('parent', 'tenant', 'description', 'comments') class RackRoleBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 92f7220da..31a6d93a4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -160,7 +160,10 @@ class LocationImportForm(NetBoxModelImportForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags') + fields = ( + 'site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', + 'tags', 'comments', + ) def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 91e23e8b1..e1535fe0c 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -179,6 +179,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): } ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')), @@ -188,7 +189,8 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags', + 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'facility', 'tags', 'comments', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 5dea2a09b..b7299c111 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -144,6 +144,7 @@ class LocationIndex(SearchIndex): ('facility', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('site', 'status', 'tenant', 'facility', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 77844f086..0209a1742 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -158,7 +158,7 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = Location fields = ( 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', - 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 08f93f6ea..1eacd7ea7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -212,12 +212,14 @@ class LocationTest(APIViewTestCases.APIViewTestCase): name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE, + comments='First!' ), Location.objects.create( site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE, + comments='Second!' ), ) @@ -227,6 +229,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE, + comments='Third!' ) Location.objects.create( site=sites[0], @@ -250,6 +253,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'site': sites[1].pk, 'parent': parent_locations[1].pk, 'status': LocationStatusChoices.STATUS_PLANNED, + 'comments': '', }, { 'name': 'Test Location 5', @@ -257,6 +261,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'site': sites[1].pk, 'parent': parent_locations[1].pk, 'status': LocationStatusChoices.STATUS_PLANNED, + 'comments': 'Somebody should check on this location', }, { 'name': 'Test Location 6', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7c9b8adc6..d8526062b 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -401,6 +401,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_PLANNED, facility='Facility 1', description='foobar1', + comments='', ), Location( name='Location 2A', @@ -410,6 +411,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_STAGING, facility='Facility 2', description='foobar2', + comments='First comment!', ), Location( name='Location 3A', @@ -419,6 +421,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_DECOMMISSIONING, facility='Facility 3', description='foobar3', + comments='_This_ is a **bold comment**', ), ) for location in locations: @@ -436,6 +439,13 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'this'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_name(self): params = {'name': ['Location 1', 'Location 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4dea94c7d..4feffcf1f 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -202,6 +202,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='', ), Location( name='Location 2', @@ -209,6 +210,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='First comment!', ), Location( name='Location 3', @@ -216,6 +218,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='_This_ is a **bold comment**', ), ) for location in locations: @@ -232,24 +235,26 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], + 'comments': 'This comment is really boring', } cls.csv_data = ( - "site,tenant,name,slug,status,description", - "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", - "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", - "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", + "site,tenant,name,slug,status,description,comments", + "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location,", + "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location,", + "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location,hi!", ) cls.csv_update_data = ( - "id,name,description", - f"{locations[0].pk},Location 7,Fourth location7", - f"{locations[1].pk},Location 8,Fifth location8", - f"{locations[2].pk},Location 0,Sixth location9", + "id,name,description,comments", + f"{locations[0].pk},Location 7,Fourth location7,Useful comment", + f"{locations[1].pk},Location 8,Fifth location8,unuseful comment", + f"{locations[2].pk},Location 0,Sixth location9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'This comment is also really boring', } From 2e2c815c9140528f0ae537b8327921e9c5d9ffe2 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 17:05:26 -0500 Subject: [PATCH 03/17] Update Location detail UI template --- netbox/templates/dcim/location.html | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 97dcc20f0..02e02a1ed 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -62,6 +62,7 @@ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
From 9a9d6cdedb6f06687b7c7a350b9ae7fcfd6a4e37 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 17:07:59 -0500 Subject: [PATCH 04/17] Adds Region.comments field in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [ ] 6. Add fields to forms - [x] dcim.forms.model_forms.RegionForm, create/edit (e.g. model_forms.py) - [x] dcim.forms.buld_edit.RegionBulkEditForm, bulk edit - [x] dcim.dorms.bulk_import.RegionImportForm, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/dcim/api/serializers_/sites.py | 2 +- netbox/dcim/filtersets.py | 9 +++++++++ netbox/dcim/forms/bulk_edit.py | 3 ++- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/model_forms.py | 3 ++- netbox/dcim/search.py | 1 + netbox/dcim/tables/sites.py | 4 ++-- netbox/dcim/tests/test_api.py | 4 +++- netbox/dcim/tests/test_filtersets.py | 19 ++++++++++++++++--- netbox/dcim/tests/test_views.py | 16 ++++++++++------ netbox/templates/dcim/region.html | 1 + 11 files changed, 48 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 1b95af70e..70924de5d 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer): model = Region fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index bd7713289..6517d277a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -110,6 +110,15 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = Region fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 696474617..dd78c0b23 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -78,12 +78,13 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = Region fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 31a6d93a4..cf9726360 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -68,7 +68,7 @@ class RegionImportForm(NetBoxModelImportForm): class Meta: model = Region - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class SiteGroupImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index e1535fe0c..639eebe13 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -78,6 +78,7 @@ class RegionForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -86,7 +87,7 @@ class RegionForm(NetBoxModelForm): class Meta: model = Region fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b7299c111..e13c97ba7 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -318,6 +318,7 @@ class RegionIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 0209a1742..51e67f2f3 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -36,8 +36,8 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Region fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1eacd7ea7..68cf34fe4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -74,6 +74,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase): { 'name': 'Region 4', 'slug': 'region-4', + 'comments': 'this is region 4, not region 5', }, { 'name': 'Region 5', @@ -86,13 +87,14 @@ class RegionTest(APIViewTestCases.APIViewTestCase): ] bulk_update_data = { 'description': 'New description', + 'comments': 'New comments', } @classmethod def setUpTestData(cls): Region.objects.create(name='Region 1', slug='region-1') - Region.objects.create(name='Region 2', slug='region-2') + Region.objects.create(name='Region 2', slug='region-2', comments='what in the world is happening?') Region.objects.create(name='Region 3', slug='region-3') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d8526062b..ebc9f3fba 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -67,9 +67,15 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): parent_regions = ( - Region(name='Region 1', slug='region-1', description='foobar1'), - Region(name='Region 2', slug='region-2', description='foobar2'), - Region(name='Region 3', slug='region-3', description='foobar3'), + Region( + name='Region 1', slug='region-1', description='foobar1', comments="There's nothing that", + ), + Region( + name='Region 2', slug='region-2', description='foobar2', comments='a hundred men or more', + ), + Region( + name='Region 3', slug='region-3', description='foobar3', comments='could ever do' + ), ) for region in parent_regions: region.save() @@ -100,6 +106,13 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'there'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'hundred men could'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + def test_name(self): params = {'name': ['Region 1', 'Region 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4feffcf1f..24fa4ae55 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -25,8 +25,10 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): # Create three Regions regions = ( - Region(name='Region 1', slug='region-1'), - Region(name='Region 2', slug='region-2'), + Region(name='Region 1', slug='region-1', comments=''), + Region( + name='Region 2', slug='region-2', comments="It's going to take a lot to drag me away from you" + ), Region(name='Region 3', slug='region-3'), ) for region in regions: @@ -40,13 +42,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'parent': regions[2].pk, 'description': 'A new region', 'tags': [t.pk for t in tags], + 'comments': 'This comment is really exciting!', } cls.csv_data = ( - "name,slug,description", - "Region 4,region-4,Fourth region", - "Region 5,region-5,Fifth region", - "Region 6,region-6,Sixth region", + "name,slug,description,comments", + "Region 4,region-4,Fourth region,", + "Region 5,region-5,Fifth region,hi guys", + "Region 6,region-6,Sixth region,bye guys", ) cls.csv_update_data = ( @@ -58,6 +61,7 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'This comment is super exciting!!!', } diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 1e1b75cd5..c6acbb9ea 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -41,6 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
From ed98756f3eb329a2343a91eb8853b9e186ec0c8a Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 10:04:50 -0500 Subject: [PATCH 05/17] Adds SiteGroup.comments in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] dcim.forms.model_forms.LocationForm, create/edit (e.g. model_forms.py) - [x] dcim.forms.buld_edit.LocationBulkEditForm, bulk edit - [x] dcim.dorms.bulk_import.LocationImportForm, CSV import - [x] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [x] 12. Update the model's documentation --- netbox/dcim/api/serializers_/sites.py | 2 +- netbox/dcim/filtersets.py | 9 +++++++++ netbox/dcim/forms/bulk_edit.py | 3 ++- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/model_forms.py | 3 ++- netbox/dcim/search.py | 1 + netbox/dcim/tables/sites.py | 4 ++-- netbox/dcim/tests/test_api.py | 8 ++++++-- netbox/dcim/tests/test_filtersets.py | 15 +++++++++++++-- netbox/dcim/tests/test_views.py | 20 +++++++++++--------- netbox/templates/dcim/sitegroup.html | 1 + 11 files changed, 49 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 70924de5d..90f7b5d35 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -41,7 +41,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer): model = SiteGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6517d277a..6fb6bf980 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -149,6 +149,15 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = SiteGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index dd78c0b23..c1da9c8d1 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -98,12 +98,13 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = SiteGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class SiteBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index cf9726360..469e40217 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -82,7 +82,7 @@ class SiteGroupImportForm(NetBoxModelImportForm): class Meta: model = SiteGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'comments', 'tags') class SiteImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 639eebe13..dea031b64 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -98,6 +98,7 @@ class SiteGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -106,7 +107,7 @@ class SiteGroupForm(NetBoxModelForm): class Meta: model = SiteGroup fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'comments', 'tags', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index e13c97ba7..a85005679 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -345,6 +345,7 @@ class SiteGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 51e67f2f3..cc4e00e7e 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -63,8 +63,8 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = SiteGroup fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 68cf34fe4..807ac77d4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -105,26 +105,30 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase): { 'name': 'Site Group 4', 'slug': 'site-group-4', + 'comments': '', }, { 'name': 'Site Group 5', 'slug': 'site-group-5', + 'comments': 'not actually empty', }, { 'name': 'Site Group 6', 'slug': 'site-group-6', + 'comments': 'Do I really exist?', }, ] bulk_update_data = { 'description': 'New description', + 'comments': 'I do exist!', } @classmethod def setUpTestData(cls): SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') - SiteGroup.objects.create(name='Site Group 2', slug='site-group-2') - SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2', comments='') + SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!') class SiteTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ebc9f3fba..0c4bbbaff 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -161,13 +161,17 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]), SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]), SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]), - SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]), + SiteGroup( + name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2], comments='this is a parent group', + ), ) for site_group in groups: site_group.save() child_groups = ( - SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]), + SiteGroup( + name='Site Group 1A1', slug='site-group-1a1', parent=groups[0], comments='this is a child group', + ), SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]), SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]), SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]), @@ -181,6 +185,13 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'this'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'q': 'child'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Site Group 1', 'Site Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 24fa4ae55..83effa188 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -73,7 +73,7 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): # Create three SiteGroups sitegroups = ( - SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 1', slug='site-group-1', comments='Still here'), SiteGroup(name='Site Group 2', slug='site-group-2'), SiteGroup(name='Site Group 3', slug='site-group-3'), ) @@ -88,24 +88,26 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'parent': sitegroups[2].pk, 'description': 'A new site group', 'tags': [t.pk for t in tags], + 'comments': 'still here', } cls.csv_data = ( - "name,slug,description", - "Site Group 4,site-group-4,Fourth site group", - "Site Group 5,site-group-5,Fifth site group", - "Site Group 6,site-group-6,Sixth site group", + "name,slug,description,comments", + "Site Group 4,site-group-4,Fourth site group,", + "Site Group 5,site-group-5,Fifth site group,still hear", + "Site Group 6,site-group-6,Sixth site group," ) cls.csv_update_data = ( - "id,name,description", - f"{sitegroups[0].pk},Site Group 7,Fourth site group7", - f"{sitegroups[1].pk},Site Group 8,Fifth site group8", - f"{sitegroups[2].pk},Site Group 0,Sixth site group9", + "id,name,description,comments", + f"{sitegroups[0].pk},Site Group 7,Fourth site group7,", + f"{sitegroups[1].pk},Site Group 8,Fifth site group8,when will it end", + f"{sitegroups[2].pk},Site Group 0,Sixth site group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'the end', } diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 3ae43f210..9beb7c505 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -41,6 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
From b8352260ee7dcf8be80281d7f45b5dc87096d3ca Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 10:42:59 -0500 Subject: [PATCH 06/17] Adds ContactGroup.comments in the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] tenancy.forms.model_forms, create/edit (e.g. model_forms.py) - [x] tenancy.forms.buld_edit, bulk edit - [x] tenancy.dorms.bulk_import, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/templates/tenancy/contactgroup.html | 1 + netbox/tenancy/api/serializers_/contacts.py | 2 +- netbox/tenancy/filtersets.py | 10 ++++++++++ netbox/tenancy/forms/bulk_edit.py | 3 ++- netbox/tenancy/forms/bulk_import.py | 2 +- netbox/tenancy/forms/model_forms.py | 3 ++- netbox/tenancy/search.py | 1 + netbox/tenancy/tables/contacts.py | 3 ++- netbox/tenancy/tests/test_api.py | 11 +++++++++-- netbox/tenancy/tests/test_filtersets.py | 17 ++++++++++++++--- netbox/tenancy/tests/test_views.py | 20 +++++++++++--------- 11 files changed, 54 insertions(+), 19 deletions(-) diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index bf6928c15..25b1da440 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -32,6 +32,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index 8c24df734..846e618b4 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/netbox/tenancy/api/serializers_/contacts.py @@ -26,7 +26,7 @@ class ContactGroupSerializer(NestedGroupModelSerializer): model = ContactGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'contact_count', '_depth', + 'created', 'last_updated', 'contact_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth') diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index e2de18231..ff5563f1a 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -51,6 +51,16 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): model = ContactGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + class ContactRoleFilterSet(OrganizationalModelFilterSet): diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 5af3f22ac..a8528aec8 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -67,12 +67,13 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = ContactGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index f37317549..d227cef14 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -65,7 +65,7 @@ class ContactGroupImportForm(NetBoxModelImportForm): class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class ContactRoleImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index bc18deed6..d65d47f1f 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -70,6 +70,7 @@ class ContactGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')), @@ -77,7 +78,7 @@ class ContactGroupForm(NetBoxModelForm): class Meta: model = ContactGroup - fields = ('parent', 'name', 'slug', 'description', 'tags') + fields = ('parent', 'name', 'slug', 'description', 'tags', 'comments') class ContactRoleForm(NetBoxModelForm): diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index 56903d6b1..5050114a6 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -25,6 +25,7 @@ class ContactGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index c4e35ab1b..e8761720e 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -31,7 +31,8 @@ class ContactGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ContactGroup fields = ( - 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created', + 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'contact_count', 'description') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c32ad3826..4dc33f943 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -107,13 +107,18 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): parent_contact_groups = ( - ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'), + ContactGroup.objects.create( + name='Parent Contact Group 1', slug='parent-contact-group-1', comments='Parent 1 comment' + ), ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'), ) ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0]) ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0]) - ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0]) + ContactGroup.objects.create( + name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0], + comments='Child Group 3 comment', + ) cls.create_data = [ { @@ -125,11 +130,13 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase): 'name': 'Contact Group 5', 'slug': 'contact-group-5', 'parent': parent_contact_groups[1].pk, + 'comments': '', }, { 'name': 'Contact Group 6', 'slug': 'contact-group-6', 'parent': parent_contact_groups[1].pk, + 'comments': 'Child Group 6 comment', }, ] diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index f6890a3d4..97005dd1e 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -139,7 +139,7 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_contact_groups = ( ContactGroup(name='Contact Group 1', slug='contact-group-1'), - ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 2', slug='contact-group-2', comments='Parent group 2'), ContactGroup(name='Contact Group 3', slug='contact-group-3'), ) for contact_group in parent_contact_groups: @@ -162,14 +162,18 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): name='Contact Group 3A', slug='contact-group-3a', parent=parent_contact_groups[2], - description='foobar3' + description='foobar3', + comments='Contact Group 3A comment, not a parent', ), ) for contact_group in contact_groups: contact_group.save() child_contact_groups = ( - ContactGroup(name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0]), + ContactGroup( + name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0], + comments='Contact Group 1A1 comment', + ), ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]), ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]), ) @@ -180,6 +184,13 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'q': '1A1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Contact Group 1', 'Contact Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index cbdecc0d0..b67ea428b 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -106,7 +106,7 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): def setUpTestData(cls): contact_groups = ( - ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 1', slug='contact-group-1', comments='Comment 1'), ContactGroup(name='Contact Group 2', slug='contact-group-2'), ContactGroup(name='Contact Group 3', slug='contact-group-3'), ) @@ -120,24 +120,26 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'contact-group-x', 'description': 'A new contact group', 'tags': [t.pk for t in tags], + 'comments': 'Form data comment', } cls.csv_data = ( - "name,slug,description", - "Contact Group 4,contact-group-4,Fourth contact group", - "Contact Group 5,contact-group-5,Fifth contact group", - "Contact Group 6,contact-group-6,Sixth contact group", + "name,slug,description,comments", + "Contact Group 4,contact-group-4,Fourth contact group,", + "Contact Group 5,contact-group-5,Fifth contact group,Fifth comment", + "Contact Group 6,contact-group-6,Sixth contact group,", ) cls.csv_update_data = ( - "id,name,description", - f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7", - f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8", - f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9", + "id,name,description,comments", + f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7,", + f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8,Group 8 comment", + f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'Bulk update comment', } From 157df20ad4354428d81da32ab8a36321b5e0318a Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 11:07:05 -0500 Subject: [PATCH 07/17] Adds TenantGroup.comments to the required locations - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] tenancy.forms.model_forms, create/edit (e.g. model_forms.py) - [x] tenancy.forms.bulk_edit, bulk edit - [x] tenancy.forms.bulk_import, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [x] 8. Add column to object table - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- netbox/templates/tenancy/tenantgroup.html | 1 + netbox/tenancy/api/serializers_/tenants.py | 2 +- netbox/tenancy/filtersets.py | 10 ++++++++++ netbox/tenancy/forms/bulk_edit.py | 3 ++- netbox/tenancy/forms/bulk_import.py | 2 +- netbox/tenancy/forms/model_forms.py | 3 ++- netbox/tenancy/search.py | 1 + netbox/tenancy/tables/tenants.py | 3 ++- netbox/tenancy/tests/test_api.py | 11 +++++++++-- netbox/tenancy/tests/test_filtersets.py | 17 ++++++++++++++--- netbox/tenancy/tests/test_views.py | 20 +++++++++++--------- 11 files changed, 54 insertions(+), 19 deletions(-) diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index 0567f2ab3..ecf95a024 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/tenancy/api/serializers_/tenants.py b/netbox/tenancy/api/serializers_/tenants.py index 54e906f1d..189397c70 100644 --- a/netbox/tenancy/api/serializers_/tenants.py +++ b/netbox/tenancy/api/serializers_/tenants.py @@ -19,7 +19,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer): model = TenantGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'tenant_count', '_depth', + 'created', 'last_updated', 'tenant_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth') diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index ff5563f1a..c70b381ee 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -202,6 +202,16 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): model = TenantGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index a8528aec8..3f72a30c1 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -33,9 +33,10 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = TenantGroup - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class TenantBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index d227cef14..61c56a70f 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -31,7 +31,7 @@ class TenantGroupImportForm(NetBoxModelImportForm): class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class TenantImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index d65d47f1f..e31a28416 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -27,6 +27,7 @@ class TenantGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')), @@ -35,7 +36,7 @@ class TenantGroupForm(NetBoxModelForm): class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments' ] diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index 5050114a6..f9441c974 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -60,5 +60,6 @@ class TenantGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index a10133a64..8c73fb5a6 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -28,7 +28,8 @@ class TenantGroupTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = TenantGroup fields = ( - 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created', + 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'tenant_count', 'description') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 4dc33f943..1804e0a8e 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -21,6 +21,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] bulk_update_data = { 'description': 'New description', + 'comments': 'New Comment', } @classmethod @@ -28,12 +29,17 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): parent_tenant_groups = ( TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), - TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), + TenantGroup.objects.create( + name='Parent Tenant Group 2', slug='parent-tenant-group-2', comments='Parent Group 2 comment', + ), ) TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0]) TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0]) - TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0]) + TenantGroup.objects.create( + name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0], + comments='Tenant Group 3 comment' + ) cls.create_data = [ { @@ -50,6 +56,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): 'name': 'Tenant Group 6', 'slug': 'tenant-group-6', 'parent': parent_tenant_groups[1].pk, + 'comments': 'Tenant Group 6 comment', }, ] diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index 97005dd1e..7d44ee45d 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -16,7 +16,7 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Parent group 2 comment'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) for tenant_group in parent_tenant_groups: @@ -27,7 +27,8 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): name='Tenant Group 1A', slug='tenant-group-1a', parent=parent_tenant_groups[0], - description='foobar1' + description='foobar1', + comments='Tenant Group 1A comment', ), TenantGroup( name='Tenant Group 2A', @@ -48,7 +49,10 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): child_tenant_groups = ( TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]), TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]), - TenantGroup(name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2]), + TenantGroup( + name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2], + comments='Tenant Group 3A1 comment', + ), ) for tenant_group in child_tenant_groups: tenant_group.save() @@ -57,6 +61,13 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_name(self): params = {'name': ['Tenant Group 1', 'Tenant Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index b67ea428b..726c9ad97 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -15,7 +15,7 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Tenant Group 2 comment'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) for tenanantgroup in tenant_groups: @@ -28,24 +28,26 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'tenant-group-x', 'description': 'A new tenant group', 'tags': [t.pk for t in tags], + 'comments': 'Tenant Group X comment', } cls.csv_data = ( - "name,slug,description", - "Tenant Group 4,tenant-group-4,Fourth tenant group", - "Tenant Group 5,tenant-group-5,Fifth tenant group", - "Tenant Group 6,tenant-group-6,Sixth tenant group", + "name,slug,description,comments", + "Tenant Group 4,tenant-group-4,Fourth tenant group,", + "Tenant Group 5,tenant-group-5,Fifth tenant group,", + "Tenant Group 6,tenant-group-6,Sixth tenant group,Sixth tenant group comment", ) cls.csv_update_data = ( - "id,name,description", - f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7", - f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8", - f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9", + "id,name,description,comments", + f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7,Group 7 comment", + f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8,", + f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'New comment', } From c0b019b735b612d3b2c9c123eb35cdb6a724ffa3 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 11:51:25 -0500 Subject: [PATCH 08/17] Adds WirelessLANGroup.comments to all the required places - [x] 1. Add the field to the model class - [x] 2. Generate and run database migrations - [NA] 3. Add validation logic to clean() - [NA] 4. Update relevant querysets - [x] 5. Update API serializer - [x] 6. Add fields to forms - [x] wireless.forms.model_forms, create/edit (e.g. model_forms.py) - [x] wireless.forms.bulk_edit, bulk edit - [x] wireless.forms.bulk_import, CSV import - [NA] filter (UI and API) - [x] 7. Extend object filter set - [NA] 8. Add column to object table (Note: was already present) - [x] 9. Update the SearchIndex - [x] 10. Update the UI templates - [x] 11. Create/extend test cases - [NA] models - [x] views - [NA] forms - [x] filtersets - [x] api - [NA] 12. Update the model's documentation --- .../templates/wireless/wirelesslangroup.html | 1 + .../wireless/api/serializers_/wirelesslans.py | 2 +- netbox/wireless/filtersets.py | 10 +++++++++ netbox/wireless/forms/bulk_edit.py | 3 ++- netbox/wireless/forms/bulk_import.py | 2 +- netbox/wireless/forms/model_forms.py | 3 ++- netbox/wireless/search.py | 1 + netbox/wireless/tests/test_api.py | 3 +++ netbox/wireless/tests/test_filtersets.py | 19 ++++++++++++++-- netbox/wireless/tests/test_views.py | 22 +++++++++++-------- 10 files changed, 51 insertions(+), 15 deletions(-) diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html index cb08b1b52..913e9da4c 100644 --- a/netbox/templates/wireless/wirelesslangroup.html +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py index 68f79daf6..97d57f9f5 100644 --- a/netbox/wireless/api/serializers_/wirelesslans.py +++ b/netbox/wireless/api/serializers_/wirelesslans.py @@ -26,7 +26,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): model = WirelessLANGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'wirelesslan_count', '_depth', + 'created', 'last_updated', 'wirelesslan_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth') diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index cc5aefbd8..17ef66c0a 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -43,6 +43,16 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): model = WirelessLANGroup fields = ('id', 'name', 'slug', 'description') + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 5cd3a157a..1a75512e1 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -32,12 +32,13 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = WirelessLANGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 1fece7e46..389dcf25d 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -30,7 +30,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm): class Meta: model = WirelessLANGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 9cfcca7ba..56422ab57 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -24,6 +24,7 @@ class WirelessLANGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')), @@ -32,7 +33,7 @@ class WirelessLANGroupForm(NetBoxModelForm): class Meta: model = WirelessLANGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments', ] diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py index e1be53c09..3c1565cb7 100644 --- a/netbox/wireless/search.py +++ b/netbox/wireless/search.py @@ -21,6 +21,7 @@ class WirelessLANGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index f768eafaf..0fe5e45f6 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -24,10 +24,12 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): { 'name': 'Wireless LAN Group 4', 'slug': 'wireless-lan-group-4', + 'comments': '', }, { 'name': 'Wireless LAN Group 5', 'slug': 'wireless-lan-group-5', + 'comments': 'LAN Group 5 comment', }, { 'name': 'Wireless LAN Group 6', @@ -36,6 +38,7 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): ] bulk_update_data = { 'description': 'New description', + 'comments': 'New comment', } @classmethod diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 27aab83d8..9e8905d4a 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -21,7 +21,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), - WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), + WirelessLANGroup( + name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C', + comments='Parent Group 3 comment', + ), ) for group in parent_groups: group.save() @@ -38,10 +41,15 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): slug='wireless-lan-group-1b', parent=parent_groups[0], description='foobar2', + comments='Child Group 1B comment', ), WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]), WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]), - WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2]), + WirelessLANGroup( + name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2], + comments='Wireless LAN Group 3A comment', + + ), WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=parent_groups[2]), ) for group in groups: @@ -62,6 +70,13 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_name(self): params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 51af37364..975f18c0d 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -16,7 +16,9 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), - WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup( + name='Wireless LAN Group 2', slug='wireless-lan-group-2', comments='LAN Group 2 comment', + ), WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), ) for group in groups: @@ -30,24 +32,26 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'parent': groups[2].pk, 'description': 'A new wireless LAN group', 'tags': [t.pk for t in tags], + 'comments': 'LAN Group X comment', } cls.csv_data = ( - "name,slug,description", - "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", - "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", - "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", + "name,slug,description,comments", + "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group,", + "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group,", + "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group,LAN Group 6 comment", ) cls.csv_update_data = ( - "id,name,description", - f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7", - f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8", - f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9", + "id,name,description,comments", + f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7,Group 7 comment", + f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8,", + f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'New Comments', } From 1ea6f6e2ce14407226596cb2b596578314f143eb Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 11:57:48 -0500 Subject: [PATCH 09/17] Ensures that all new comments fields render Markdown in tables --- netbox/dcim/tables/sites.py | 9 +++++++++ netbox/tenancy/tables/contacts.py | 3 +++ netbox/tenancy/tables/tenants.py | 3 +++ 3 files changed, 15 insertions(+) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index cc4e00e7e..7d2f0e0cc 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -32,6 +32,9 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:region_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Region @@ -59,6 +62,9 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = SiteGroup @@ -153,6 +159,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): actions = columns.ActionsColumn( extra_buttons=LOCATION_BUTTONS ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Location diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index e8761720e..ded6315ea 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -27,6 +27,9 @@ class ContactGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:contactgroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = ContactGroup diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index 8c73fb5a6..70f263dbe 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -24,6 +24,9 @@ class TenantGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:tenantgroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = TenantGroup From 2df68e29c9b9944a8ed48ae04671d9391081a3c2 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 11 Mar 2025 12:00:45 -0500 Subject: [PATCH 10/17] Ensures overridden filterset search() methods include fields from OrganizationalModelFilterSet --- netbox/dcim/filtersets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6fb6bf980..443a2dc36 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -115,6 +115,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | + Q(slug__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() @@ -154,6 +155,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | + Q(slug__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() @@ -297,6 +299,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM return queryset return queryset.filter( Q(name__icontains=value) | + Q(slug__icontains=value) | Q(facility__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) From 06a206ee33853ae07a6f29067f0cd74ce13efe62 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 13 Mar 2025 15:36:55 -0500 Subject: [PATCH 11/17] Extract base NestedGroupModelFilterSet with base search behavior This can easily be extended (as in the case of LocationFilterSet) by calling super() and ORing a filter to the queryset that is returned. See: https://docs.djangoproject.com/en/5.1/ref/models/querysets/#or --- netbox/dcim/filtersets.py | 45 ++++++++++------------------------- netbox/netbox/filtersets.py | 16 +++++++++++++ netbox/tenancy/filtersets.py | 26 +++----------------- netbox/wireless/filtersets.py | 14 ++--------- 4 files changed, 33 insertions(+), 68 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 443a2dc36..6f9f481c3 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -11,7 +11,8 @@ from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, + BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet, + OrganizationalModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * @@ -81,7 +82,7 @@ __all__ = ( ) -class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): +class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label=_('Parent region (ID)'), @@ -110,18 +111,8 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = Region fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ).distinct() - -class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): +class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=SiteGroup.objects.all(), label=_('Parent site group (ID)'), @@ -150,16 +141,6 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): model = SiteGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ).distinct() - class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( @@ -225,7 +206,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe return queryset.filter(qs_filter).distinct() -class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): +class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -295,15 +276,13 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM fields = ('id', 'name', 'slug', 'facility', 'description') def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(facility__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) + # extended in order to include querying on Location.facility + queryset = super().search(queryset, name, value) + + if value.strip(): + queryset = queryset | queryset.model.objects.filter(facility__icontains=value) + + return queryset class RackRoleFilterSet(OrganizationalModelFilterSet): diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index b8fbe7ad5..d80b07e90 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -329,3 +329,19 @@ class OrganizationalModelFilterSet(NetBoxModelFilterSet): models.Q(slug__icontains=value) | models.Q(description__icontains=value) ) + + +class NestedGroupModelFilterSet(NetBoxModelFilterSet): + """ + A base FilterSet for models that inherit from NestedGroupModel + """ + def search(self, queryset, name, value): + if value.strip(): + queryset = queryset.filter( + models.Q(name__icontains=value) | + models.Q(slug__icontains=value) | + models.Q(description__icontains=value) | + models.Q(comments__icontains=value) + ) + + return queryset diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index c70b381ee..db7236abe 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ -from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet +from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -22,7 +22,7 @@ __all__ = ( # Contacts # -class ContactGroupFilterSet(OrganizationalModelFilterSet): +class ContactGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactGroup.objects.all(), label=_('Parent contact group (ID)'), @@ -51,16 +51,6 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): model = ContactGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) - class ContactRoleFilterSet(OrganizationalModelFilterSet): @@ -173,7 +163,7 @@ class ContactModelFilterSet(django_filters.FilterSet): # Tenancy # -class TenantGroupFilterSet(OrganizationalModelFilterSet): +class TenantGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), label=_('Parent tenant group (ID)'), @@ -202,16 +192,6 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): model = TenantGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) - class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 17ef66c0a..bd96865ad 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices from dcim.base_filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN -from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -18,7 +18,7 @@ __all__ = ( ) -class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): +class WirelessLANGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all() ) @@ -43,16 +43,6 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): model = WirelessLANGroup fields = ('id', 'name', 'slug', 'description') - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) | - Q(description__icontains=value) | - Q(comments__icontains=value) - ) - class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( From b45e256f27670e6abae516f72bce35720d92995f Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 13 Mar 2025 15:43:32 -0500 Subject: [PATCH 12/17] Removes banner from new migrations --- ...0202_location_comments_region_comments_sitegroup_comments.py | 2 -- .../0018_contactgroup_comments_tenantgroup_comments.py | 2 -- netbox/wireless/migrations/0014_wirelesslangroup_comments.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py index 51031de53..ffdc5ba8a 100644 --- a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py +++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py @@ -1,5 +1,3 @@ -# Generated by Django 5.1.7 on 2025-03-10 16:37 - from django.db import migrations, models diff --git a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py index 3481baeec..5f6a95149 100644 --- a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py +++ b/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py @@ -1,5 +1,3 @@ -# Generated by Django 5.1.7 on 2025-03-10 16:37 - from django.db import migrations, models diff --git a/netbox/wireless/migrations/0014_wirelesslangroup_comments.py b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py index 3e3cab270..9fc1a99d6 100644 --- a/netbox/wireless/migrations/0014_wirelesslangroup_comments.py +++ b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py @@ -1,5 +1,3 @@ -# Generated by Django 5.1.7 on 2025-03-10 16:37 - from django.db import migrations, models From af5ec19430f3103b79ce414ee203398d46f4c643 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 18 Mar 2025 11:05:02 -0700 Subject: [PATCH 13/17] 17170 Add ability to add contacts to multiple contact groups (#18885) * 17170 Allow multiple Group assignments for Contacts * 17170 update docs * 17170 update api, detail view, graphql * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fixes * 17170 fix bulk import * 17170 test fixes * 17170 test fixes * 17170 test fixes * 17178 review changes * 17178 review changes * 17178 review changes * 17178 review changes * 17178 review changes * 17178 review changes * 17170 update migration * 17170 bulk edit form --- docs/models/tenancy/contact.md | 6 +- netbox/templates/tenancy/contact.html | 14 +++- netbox/tenancy/api/serializers_/contacts.py | 11 ++- netbox/tenancy/api/views.py | 2 +- netbox/tenancy/filtersets.py | 17 +++-- netbox/tenancy/forms/bulk_edit.py | 19 ++++-- netbox/tenancy/forms/bulk_import.py | 9 ++- netbox/tenancy/forms/filtersets.py | 2 +- netbox/tenancy/forms/model_forms.py | 12 ++-- netbox/tenancy/graphql/types.py | 2 +- .../tenancy/migrations/0018_contact_groups.py | 68 +++++++++++++++++++ netbox/tenancy/models/contacts.py | 29 +++++--- netbox/tenancy/tables/contacts.py | 10 +-- netbox/tenancy/tests/test_api.py | 14 ++-- netbox/tenancy/tests/test_filtersets.py | 19 ++++-- netbox/tenancy/tests/test_views.py | 29 ++++---- netbox/tenancy/views.py | 15 +++- netbox/utilities/testing/filtersets.py | 4 +- 18 files changed, 204 insertions(+), 78 deletions(-) create mode 100644 netbox/tenancy/migrations/0018_contact_groups.py diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md index eac630180..f277ab499 100644 --- a/docs/models/tenancy/contact.md +++ b/docs/models/tenancy/contact.md @@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj ## Fields -### Group +### Groups -The [contact group](./contactgroup.md) to which this contact is assigned (if any). +The [contact groups](./contactgroup.md) to which this contact is assigned (if any). + +!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group." ### Name diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index b2d1a4078..f34a3573f 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -18,8 +18,18 @@

{% trans "Contact" %}

- - + + diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index 8c24df734..a5c4dd741 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/netbox/tenancy/api/serializers_/contacts.py @@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole @@ -43,12 +43,17 @@ class ContactRoleSerializer(NetBoxModelSerializer): class ContactSerializer(NetBoxModelSerializer): - group = ContactGroupSerializer(nested=True, required=False, allow_null=True, default=None) + groups = SerializedPKRelatedField( + queryset=ContactGroup.objects.all(), + serializer=ContactGroupSerializer, + required=False, + many=True + ) class Meta: model = Contact fields = [ - 'id', 'url', 'display_url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + 'id', 'url', 'display_url', 'display', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 70330ddb8..371b8ec24 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index e2de18231..0042ca427 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -46,6 +46,11 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label=_('Contact group (slug)'), ) + contact_id = django_filters.ModelMultipleChoiceFilter( + field_name='contact', + queryset=Contact.objects.all(), + label=_('Contact (ID)'), + ) class Meta: model = ContactGroup @@ -62,15 +67,15 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): class ContactFilterSet(NetBoxModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', + field_name='groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', - lookup_expr='in', + field_name='groups', to_field_name='slug', + lookup_expr='in', label=_('Contact group (slug)'), ) @@ -105,13 +110,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet): ) group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', to_field_name='slug', label=_('Contact group (slug)'), @@ -153,7 +158,7 @@ class ContactModelFilterSet(django_filters.FilterSet): ) contact_group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contacts__contact__group', + field_name='contacts__contact__groups', lookup_expr='in', label=_('Contact group'), ) diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 5af3f22ac..63eaaad9d 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -5,7 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.choices import ContactPriorityChoices from tenancy.models import * from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet __all__ = ( @@ -90,8 +90,13 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): class ContactBulkEditForm(NetBoxModelBulkEditForm): - group = DynamicModelChoiceField( - label=_('Group'), + add_groups = DynamicModelMultipleChoiceField( + label=_('Add groups'), + queryset=ContactGroup.objects.all(), + required=False + ) + remove_groups = DynamicModelMultipleChoiceField( + label=_('Remove groups'), queryset=ContactGroup.objects.all(), required=False ) @@ -127,9 +132,13 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): model = Contact fieldsets = ( - FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'), + FieldSet('title', 'phone', 'email', 'address', 'link', 'description'), + FieldSet('add_groups', 'remove_groups', name=_('Groups')), + ) + + nullable_fields = ( + 'add_groups', 'remove_groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments' ) - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments') class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index f37317549..c4f48347a 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm from tenancy.models import * -from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField __all__ = ( 'ContactAssignmentImportForm', @@ -77,17 +77,16 @@ class ContactRoleImportForm(NetBoxModelImportForm): class ContactImportForm(NetBoxModelImportForm): - group = CSVModelChoiceField( - label=_('Group'), + groups = CSVModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, to_field_name='name', - help_text=_('Assigned group') + help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")') ) class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'comments', 'tags') class ContactAssignmentImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 960ca45b1..6541d9693 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -75,7 +75,7 @@ class ContactFilterForm(NetBoxModelFilterSetForm): queryset=ContactGroup.objects.all(), required=False, null_option='None', - label=_('Group') + label=_('Groups') ) tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index bc18deed6..65beebca9 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm from tenancy.models import * -from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.rendering import FieldSet, ObjectAttribute __all__ = ( @@ -93,8 +93,8 @@ class ContactRoleForm(NetBoxModelForm): class ContactForm(NetBoxModelForm): - group = DynamicModelChoiceField( - label=_('Group'), + groups = DynamicModelMultipleChoiceField( + label=_('Groups'), queryset=ContactGroup.objects.all(), required=False ) @@ -102,7 +102,7 @@ class ContactForm(NetBoxModelForm): fieldsets = ( FieldSet( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', name=_('Contact') ), ) @@ -110,7 +110,7 @@ class ContactForm(NetBoxModelForm): class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { 'address': forms.Textarea(attrs={'rows': 3}), @@ -123,7 +123,7 @@ class ContactAssignmentForm(NetBoxModelForm): queryset=ContactGroup.objects.all(), required=False, initial_params={ - 'contacts': '$contact' + 'contact': '$contact' } ) contact = DynamicModelChoiceField( diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index c340cdf7c..b47ac2da3 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -97,7 +97,7 @@ class TenantGroupType(OrganizationalObjectType): @strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter) class ContactType(ContactAssignmentsMixin, NetBoxObjectType): - group: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None + groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] @strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter) diff --git a/netbox/tenancy/migrations/0018_contact_groups.py b/netbox/tenancy/migrations/0018_contact_groups.py new file mode 100644 index 000000000..11030eb49 --- /dev/null +++ b/netbox/tenancy/migrations/0018_contact_groups.py @@ -0,0 +1,68 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def migrate_contact_groups(apps, schema_editor): + Contacts = apps.get_model('tenancy', 'Contact') + + qs = Contacts.objects.filter(group__isnull=False) + for contact in qs: + contact.groups.add(contact.group) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0017_natural_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='ContactGroupMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ], + options={ + 'verbose_name': 'contact group membership', + 'verbose_name_plural': 'contact group memberships', + }, + ), + migrations.RemoveConstraint( + model_name='contact', + name='tenancy_contact_unique_group_name', + ), + migrations.AddField( + model_name='contactgroupmembership', + name='contact', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contact' + ), + ), + migrations.AddField( + model_name='contactgroupmembership', + name='group', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contactgroup' + ), + ), + migrations.AddField( + model_name='contact', + name='groups', + field=models.ManyToManyField( + blank=True, + related_name='contacts', + related_query_name='contact', + through='tenancy.ContactGroupMembership', + to='tenancy.contactgroup', + ), + ), + migrations.AddConstraint( + model_name='contactgroupmembership', + constraint=models.UniqueConstraint(fields=('group', 'contact'), name='unique_group_name'), + ), + migrations.RunPython(code=migrate_contact_groups, reverse_code=migrations.RunPython.noop), + migrations.RemoveField( + model_name='contact', + name='group', + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 3969c8317..5f39fe0db 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -13,6 +13,7 @@ __all__ = ( 'ContactAssignment', 'Contact', 'ContactGroup', + 'ContactGroupMembership', 'ContactRole', ) @@ -47,12 +48,12 @@ class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. """ - group = models.ForeignKey( + groups = models.ManyToManyField( to='tenancy.ContactGroup', - on_delete=models.SET_NULL, related_name='contacts', - blank=True, - null=True + through='tenancy.ContactGroupMembership', + related_query_name='contact', + blank=True ) name = models.CharField( verbose_name=_('name'), @@ -84,17 +85,11 @@ class Contact(PrimaryModel): ) clone_fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', ) class Meta: ordering = ['name'] - constraints = ( - models.UniqueConstraint( - fields=('group', 'name'), - name='%(app_label)s_%(class)s_unique_group_name' - ), - ) verbose_name = _('contact') verbose_name_plural = _('contacts') @@ -102,6 +97,18 @@ class Contact(PrimaryModel): return self.name +class ContactGroupMembership(models.Model): + group = models.ForeignKey(ContactGroup, related_name="+", on_delete=models.CASCADE) + contact = models.ForeignKey(Contact, related_name="+", on_delete=models.CASCADE) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['group', 'contact'], name='unique_group_name') + ] + verbose_name = _('contact group membership') + verbose_name_plural = _('contact group memberships') + + class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): object_type = models.ForeignKey( to='contenttypes.ContentType', diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index c4e35ab1b..07af8382e 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -56,9 +56,9 @@ class ContactTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - group = tables.Column( - verbose_name=_('Group'), - linkify=True + groups = columns.ManyToManyColumn( + verbose_name=_('Groups'), + linkify_item=('tenancy:contactgroup', {'pk': tables.A('pk')}) ) phone = tables.Column( verbose_name=_('Phone'), @@ -79,10 +79,10 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', + 'pk', 'name', 'groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'assignment_count', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') + default_columns = ('pk', 'name', 'groups', 'assignment_count', 'title', 'phone', 'email') class ContactAssignmentTable(NetBoxTable): diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c32ad3826..3bacb8fea 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -170,7 +170,7 @@ class ContactTest(APIViewTestCases.APIViewTestCase): model = Contact brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { - 'group': None, + 'groups': [], 'comments': 'New comments', } @@ -183,20 +183,22 @@ class ContactTest(APIViewTestCases.APIViewTestCase): ) contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[0]), - Contact(name='Contact 3', group=contact_groups[0]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[0]) + contacts[2].groups.add(contact_groups[0]) cls.create_data = [ { 'name': 'Contact 4', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], }, { 'name': 'Contact 5', - 'group': contact_groups[1].pk, }, { 'name': 'Contact 6', diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index f6890a3d4..d44d78ec4 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -241,6 +241,7 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Contact.objects.all() filterset = ContactFilterSet + ignore_fields = ('groups',) @classmethod def setUpTestData(cls): @@ -254,11 +255,14 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): contactgroup.save() contacts = ( - Contact(name='Contact 1', group=contact_groups[0], description='foobar1'), - Contact(name='Contact 2', group=contact_groups[1], description='foobar2'), - Contact(name='Contact 3', group=contact_groups[2], description='foobar3'), + Contact(name='Contact 1', description='foobar1'), + Contact(name='Contact 2', description='foobar2'), + Contact(name='Contact 3', description='foobar3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) + contacts[2].groups.add(contact_groups[2]) def test_q(self): params = {'q': 'foobar1'} @@ -311,11 +315,14 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): ContactRole.objects.bulk_create(contact_roles) contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[1]), - Contact(name='Contact 3', group=contact_groups[2]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) + contacts[2].groups.add(contact_groups[2]) assignments = ( ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]), diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index cbdecc0d0..ec962e6e6 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -196,37 +196,40 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): contactgroup.save() contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[0]), - Contact(name='Contact 3', group=contact_groups[0]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Contact X', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], 'comments': 'Some comments', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "group,name", - "Contact Group 1,Contact 4", - "Contact Group 1,Contact 5", - "Contact Group 1,Contact 6", + "name", + "groups", + "Contact 4", + "Contact 5", + "Contact 6", ) cls.csv_update_data = ( - "id,name,comments", - f"{contacts[0].pk},Contact Group 7,New comments 7", - f"{contacts[1].pk},Contact Group 8,New comments 8", - f"{contacts[2].pk},Contact Group 9,New comments 9", + "id,name,groups,comments", + f'{contacts[0].pk},Contact 7,"Contact Group 1,Contact Group 2",New comments 7', + f'{contacts[1].pk},Contact 8,"Contact Group 1",New comments 8', + f'{contacts[2].pk},Contact 9,"Contact Group 1",New comments 9', ) cls.bulk_edit_data = { - 'group': contact_groups[1].pk, + 'description': "New description", } diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 3b5029bd7..d0c80b76f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -170,7 +170,7 @@ class ContactGroupListView(generic.ObjectListView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -214,7 +214,7 @@ class ContactGroupBulkEditView(generic.BulkEditView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -228,7 +228,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -337,6 +337,15 @@ class ContactBulkEditView(generic.BulkEditView): table = tables.ContactTable form = forms.ContactBulkEditForm + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + + # Add/remove groups + if form.cleaned_data.get('add_groups', None): + obj.groups.add(*form.cleaned_data['add_groups']) + if form.cleaned_data.get('remove_groups', None): + obj.groups.remove(*form.cleaned_data['remove_groups']) + @register_model_view(Contact, 'bulk_delete', path='delete', detail=False) class ContactBulkDeleteView(generic.BulkDeleteView): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index e58123f03..0b3d4b198 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -144,8 +144,8 @@ class BaseFilterSetTests: # Check that the filter class is correct filter = filters[filter_name] if filter_class is not None: - self.assertIs( - type(filter), + self.assertIsInstance( + filter, filter_class, f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!" ) From 958dcca8d6fb0bc7ca241f8f39b9adf7fef2e4dd Mon Sep 17 00:00:00 2001 From: bctiemann Date: Wed, 19 Mar 2025 10:38:10 -0400 Subject: [PATCH 14/17] Fix migration conflict in tenancy (#18957) --- ...ts.py => 0019_contactgroup_comments_tenantgroup_comments.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/tenancy/migrations/{0018_contactgroup_comments_tenantgroup_comments.py => 0019_contactgroup_comments_tenantgroup_comments.py} (90%) diff --git a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py b/netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py similarity index 90% rename from netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py rename to netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py index 5f6a95149..eee2dc351 100644 --- a/netbox/tenancy/migrations/0018_contactgroup_comments_tenantgroup_comments.py +++ b/netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('tenancy', '0017_natural_ordering'), + ('tenancy', '0018_contact_groups'), ] operations = [ From d25605c261a75e4da6c07f0b9f5e5ebba84e3f71 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 19 Mar 2025 10:40:54 -0400 Subject: [PATCH 15/17] Closes #18751: Set the default value of `ALLOW_TOKEN_RETRIEVAL` to False (#18943) * Closes #18751: Set the default value of ALLOW_TOKEN_RETRIEVAL to False * Enable token retrieval during testing --- docs/configuration/security.md | 5 ++++- netbox/netbox/configuration_testing.py | 2 ++ netbox/netbox/settings.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 172034b4f..950d2df34 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -2,7 +2,10 @@ ## ALLOW_TOKEN_RETRIEVAL -Default: True +Default: False + +!!! note + The default value of this parameter changed from true to false in NetBox v4.3.0. If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 142b50bb0..52973e94d 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -43,6 +43,8 @@ SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' DEFAULT_PERMISSIONS = {} +ALLOW_TOKEN_RETRIEVAL = True + LOGGING = { 'version': 1, 'disable_existing_loggers': True diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3b47ab541..9f9b25689 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -64,7 +64,7 @@ elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'): # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) -ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) +ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False) ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [ { From 6b7d23d6847dd9bd1bbc055249b0918a3f64b03d Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 19 Mar 2025 12:17:35 -0500 Subject: [PATCH 16/17] Closes #17841 Allows Tags to be displayed in specified order (#18930) --- docs/models/extras/tag.md | 6 ++++ netbox/extras/api/serializers_/tags.py | 4 +-- netbox/extras/filtersets.py | 2 +- netbox/extras/forms/bulk_edit.py | 4 +++ netbox/extras/forms/bulk_import.py | 6 +++- netbox/extras/forms/model_forms.py | 8 +++-- .../0124_alter_tag_options_tag_weight.py | 22 ++++++++++++ netbox/extras/models/tags.py | 6 +++- netbox/extras/tables/tables.py | 4 +-- netbox/extras/tests/test_api.py | 3 +- netbox/extras/tests/test_filtersets.py | 9 ++++- netbox/extras/tests/test_models.py | 34 +++++++++++++++++++ netbox/extras/tests/test_views.py | 13 +++---- netbox/netbox/models/features.py | 3 +- netbox/templates/extras/tag.html | 4 +++ 15 files changed, 110 insertions(+), 18 deletions(-) create mode 100644 netbox/extras/migrations/0124_alter_tag_options_tag_weight.py diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 39de48261..c4bc91b5a 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This The color to use when displaying the tag in the NetBox UI. +### Weight + +A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**. + +!!! info "This field was introduced in NetBox v4.3." + ### Object Types The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py index ea964ff52..5dc39584f 100644 --- a/netbox/extras/api/serializers_/tags.py +++ b/netbox/extras/api/serializers_/tags.py @@ -27,8 +27,8 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types', - 'tagged_items', 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'weight', + 'object_types', 'tagged_items', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 98302d0f4..635102be2 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -450,7 +450,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet): class Meta: model = Tag - fields = ('id', 'name', 'slug', 'color', 'description', 'object_types') + fields = ('id', 'name', 'slug', 'color', 'weight', 'description', 'object_types') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 30d06683b..7adc303f5 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -275,6 +275,10 @@ class TagBulkEditForm(BulkEditForm): max_length=200, required=False ) + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) nullable_fields = ('description',) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 655a5d6ca..b680382f6 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -232,10 +232,14 @@ class EventRuleImportForm(NetBoxModelImportForm): class TagImportForm(CSVModelForm): slug = SlugField() + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) class Meta: model = Tag - fields = ('name', 'slug', 'color', 'description') + fields = ('name', 'slug', 'color', 'weight', 'description') class JournalEntryImportForm(NetBoxModelImportForm): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index a45daaf70..5591de754 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -490,15 +490,19 @@ class TagForm(forms.ModelForm): queryset=ObjectType.objects.with_feature('tags'), required=False ) + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) fieldsets = ( - FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')), + FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')), ) class Meta: model = Tag fields = [ - 'name', 'slug', 'color', 'description', 'object_types', + 'name', 'slug', 'color', 'weight', 'description', 'object_types', ] diff --git a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py new file mode 100644 index 000000000..86fc71fd5 --- /dev/null +++ b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2b1 on 2025-03-17 14:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0123_remove_staging'), + ] + + operations = [ + migrations.AlterModelOptions( + name='tag', + options={'ordering': ('weight', 'name')}, + ), + migrations.AddField( + model_name='tag', + name='weight', + field=models.PositiveSmallIntegerField(default=0), + ), + ] diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index baf72baa1..4c6396172 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -40,13 +40,17 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, help_text=_("The object type(s) to which this tag can be applied.") ) + weight = models.PositiveSmallIntegerField( + verbose_name=_('weight'), + default=0, + ) clone_fields = ( 'color', 'description', 'object_types', ) class Meta: - ordering = ['name'] + ordering = ('weight', 'name') verbose_name = _('tag') verbose_name_plural = _('tags') diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e538c488e..a14356c1c 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -449,8 +449,8 @@ class TagTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Tag fields = ( - 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'items', 'slug', 'color', 'weight', 'description', 'object_types', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 17f03350d..1d6dfac6d 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -513,6 +513,7 @@ class TagTest(APIViewTestCases.APIViewTestCase): { 'name': 'Tag 4', 'slug': 'tag-4', + 'weight': 1000, }, { 'name': 'Tag 5', @@ -533,7 +534,7 @@ class TagTest(APIViewTestCases.APIViewTestCase): tags = ( Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 2', slug='tag-2'), - Tag(name='Tag 3', slug='tag-3'), + Tag(name='Tag 3', slug='tag-3', weight=26), ) Tag.objects.bulk_create(tags) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 9684b3dbe..c6c53bfcb 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1196,7 +1196,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): tags = ( Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'), - Tag(name='Tag 3', slug='tag-3', color='0000ff'), + Tag(name='Tag 3', slug='tag-3', color='0000ff', weight=1000), ) Tag.objects.bulk_create(tags) tags[0].object_types.add(object_types['site']) @@ -1249,6 +1249,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): ['Tag 2', 'Tag 3'] ) + def test_weight(self): + params = {'weight': [1000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'weight': [0]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class TaggedItemFilterSetTestCase(TestCase): queryset = TaggedItem.objects.all() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index c90390dd1..bf05a8c18 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -10,6 +10,40 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac class TagTest(TestCase): + def test_default_ordering_weight_then_name_is_set(self): + Tag.objects.create(name='Tag 1', slug='tag-1', weight=100) + Tag.objects.create(name='Tag 2', slug='tag-2') + Tag.objects.create(name='Tag 3', slug='tag-3', weight=10) + Tag.objects.create(name='Tag 4', slug='tag-4', weight=10) + + tags = Tag.objects.all() + + self.assertEqual(tags[0].slug, 'tag-2') + self.assertEqual(tags[1].slug, 'tag-3') + self.assertEqual(tags[2].slug, 'tag-4') + self.assertEqual(tags[3].slug, 'tag-1') + + def test_tag_related_manager_ordering_weight_then_name(self): + tags = [ + Tag.objects.create(name='Tag 1', slug='tag-1', weight=100), + Tag.objects.create(name='Tag 2', slug='tag-2'), + Tag.objects.create(name='Tag 3', slug='tag-3', weight=10), + Tag.objects.create(name='Tag 4', slug='tag-4', weight=10), + ] + + site = Site.objects.create(name='Site 1') + for tag in tags: + site.tags.add(tag) + site.save() + + site = Site.objects.first() + tags = site.tags.all() + + self.assertEqual(tags[0].slug, 'tag-2') + self.assertEqual(tags[1].slug, 'tag-3') + self.assertEqual(tags[2].slug, 'tag-4') + self.assertEqual(tags[3].slug, 'tag-1') + def test_create_tag_unicode(self): tag = Tag(name='Testing Unicode: 台灣') tag.save() diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 5d82fae4c..be8d80b2b 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -441,8 +441,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tags = ( Tag(name='Tag 1', slug='tag-1'), - Tag(name='Tag 2', slug='tag-2'), - Tag(name='Tag 3', slug='tag-3'), + Tag(name='Tag 2', slug='tag-2', weight=1), + Tag(name='Tag 3', slug='tag-3', weight=32767), ) Tag.objects.bulk_create(tags) @@ -451,13 +451,14 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'tag-x', 'color': 'c0c0c0', 'comments': 'Some comments', + 'weight': 11, } cls.csv_data = ( - "name,slug,color,description", - "Tag 4,tag-4,ff0000,Fourth tag", - "Tag 5,tag-5,00ff00,Fifth tag", - "Tag 6,tag-6,0000ff,Sixth tag", + "name,slug,color,description,weight", + "Tag 4,tag-4,ff0000,Fourth tag,0", + "Tag 5,tag-5,00ff00,Fifth tag,1111", + "Tag 6,tag-6,0000ff,Sixth tag,0", ) cls.csv_update_data = ( diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e58037b85..a2fb8d615 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -455,7 +455,8 @@ class TagsMixin(models.Model): which is a `TaggableManager` instance. """ tags = TaggableManager( - through='extras.TaggedItem' + through='extras.TaggedItem', + ordering=('weight', 'name'), ) class Meta: diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 4e1379fed..8c5eb13cd 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -28,6 +28,10 @@   + + + + + + + +
{% trans "Group" %}{{ object.group|linkify|placeholder }}{% trans "Groups" %} + {% if object.groups.all|length > 0 %} +
    + {% for group in object.groups.all %} +
  1. {{ group|linkify|placeholder }}
  2. + {% endfor %} +
+ {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Name" %}
{% trans "Weight" %}{{ object.weight }}
{% trans "Tagged Items" %} From 80440fd0250d6b66074a5a8284a4f96058aa6ed9 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 20 Mar 2025 08:17:56 -0500 Subject: [PATCH 17/17] Fixes #17443: Adds ExportTemplate.file_name field (#18911) * Fixes #17443: Adds ExportTemplate.file_name field * Addresses PR feedback - Adds `file_name` to `ExportTemplateBulkEditForm.nullable_fields` - Shortens max length of `ExportTemplate.file_name` to 200 chars - Adds tests for `ExportTemplateFilterSet.file_extension` * Fixes migration conflict caused by fix for #17841 --- docs/models/extras/exporttemplate.md | 6 +++++ .../api/serializers_/exporttemplates.py | 4 ++-- netbox/extras/filtersets.py | 7 +++--- netbox/extras/forms/bulk_edit.py | 6 ++++- netbox/extras/forms/bulk_import.py | 3 ++- netbox/extras/forms/filtersets.py | 6 ++++- netbox/extras/forms/model_forms.py | 2 +- .../0124_alter_tag_options_tag_weight.py | 2 -- .../0125_exporttemplate_file_name.py | 16 +++++++++++++ netbox/extras/models/models.py | 15 ++++++++---- netbox/extras/tables/tables.py | 7 +++--- netbox/extras/tests/test_api.py | 9 +++++-- netbox/extras/tests/test_filtersets.py | 24 +++++++++++++++++-- netbox/extras/tests/test_utils.py | 19 +++++++++++++++ netbox/extras/tests/test_views.py | 11 +++++---- netbox/extras/utils.py | 7 ++++++ netbox/templates/extras/exporttemplate.html | 4 ++++ 17 files changed, 120 insertions(+), 28 deletions(-) create mode 100644 netbox/extras/migrations/0125_exporttemplate_file_name.py create mode 100644 netbox/extras/tests/test_utils.py diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index d2f9292c6..73be522b8 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -24,6 +24,12 @@ Jinja2 template code for rendering the exported data. The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`. +### File Name + +The file name to give to the rendered export file (optional). + +!!! info "This field was introduced in NetBox v4.3." + ### File Extension The file extension to append to the file name in the response (optional). diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py index 11f502a02..ad77cd1f7 100644 --- a/netbox/extras/api/serializers_/exporttemplates.py +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -27,7 +27,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer): model = ExportTemplate fields = [ 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', - 'last_updated', + 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', + 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 635102be2..e63b6d673 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -258,8 +258,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): class Meta: model = ExportTemplate fields = ( - 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled', - 'data_synced', + 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', + 'auto_sync_enabled', 'data_synced', ) def search(self, queryset, name, value): @@ -267,7 +267,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | - Q(description__icontains=value) + Q(description__icontains=value) | + Q(file_name__icontains=value) ) diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 7adc303f5..6891edc5d 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -155,6 +155,10 @@ class ExportTemplateBulkEditForm(BulkEditForm): max_length=50, required=False ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) file_extension = forms.CharField( label=_('File extension'), max_length=15, @@ -166,7 +170,7 @@ class ExportTemplateBulkEditForm(BulkEditForm): widget=BulkEditNullBooleanSelect() ) - nullable_fields = ('description', 'mime_type', 'file_extension') + nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') class SavedFilterBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index b680382f6..fb522bd7b 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm): class Meta: model = ExportTemplate fields = ( - 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', + 'template_code', ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 05dcf96c4..1691559f9 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -162,7 +162,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( FieldSet('q', 'filter_id'), FieldSet('data_source_id', 'data_file_id', name=_('Data')), - FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')), + FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -186,6 +186,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('MIME type') ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) file_extension = forms.CharField( label=_('File extension'), required=False diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 5591de754..b5bc06b40 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -246,7 +246,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): fieldsets = ( FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')), FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), - FieldSet('mime_type', 'file_extension', 'as_attachment', name=_('Rendering')), + FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')), ) class Meta: diff --git a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py index 86fc71fd5..759ad1595 100644 --- a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py +++ b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py @@ -1,5 +1,3 @@ -# Generated by Django 5.2b1 on 2025-03-17 14:41 - from django.db import migrations, models diff --git a/netbox/extras/migrations/0125_exporttemplate_file_name.py b/netbox/extras/migrations/0125_exporttemplate_file_name.py new file mode 100644 index 000000000..2c8ac118b --- /dev/null +++ b/netbox/extras/migrations/0125_exporttemplate_file_name.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0124_alter_tag_options_tag_weight'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='file_name', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index d3e443b14..3cae54f29 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -16,7 +16,7 @@ from core.models import ObjectType from extras.choices import * from extras.conditions import ConditionSet from extras.constants import * -from extras.utils import image_upload +from extras.utils import filename_from_model, image_upload from netbox.config import get_config from netbox.events import get_event_type_choices from netbox.models import ChangeLoggedModel @@ -409,6 +409,11 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change verbose_name=_('MIME type'), help_text=_('Defaults to text/plain; charset=utf-8') ) + file_name = models.CharField( + max_length=200, + blank=True, + help_text=_('Filename to give to the rendered export file') + ) file_extension = models.CharField( verbose_name=_('file extension'), max_length=15, @@ -422,7 +427,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change ) clone_fields = ( - 'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment', + 'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', ) class Meta: @@ -480,10 +485,10 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change response = HttpResponse(output, content_type=mime_type) if self.as_attachment: - basename = queryset.model._meta.verbose_name_plural.replace(' ', '_') extension = f'.{self.file_extension}' if self.file_extension else '' - filename = f'netbox_{basename}{extension}' - response['Content-Disposition'] = f'attachment; filename="{filename}"' + filename = self.file_name or filename_from_model(queryset.model) + full_filename = f'{filename}{extension}' + response['Content-Disposition'] = f'attachment; filename="{full_filename}"' return response diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index a14356c1c..7a6e79cab 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -203,11 +203,12 @@ class ExportTemplateTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( - 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', - 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', + 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', + 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced', + 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', + 'as_attachment', 'is_synced', ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 1d6dfac6d..7a4d63549 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): 'object_types': ['dcim.device'], 'name': 'Test Export Template 6', 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', + 'file_name': 'test_export_template_6', }, ] bulk_update_data = { @@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): ), ExportTemplate( name='Export Template 2', - template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}' + template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', + file_name='export_template_2', + file_extension='test', ), ExportTemplate( name='Export Template 3', @@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): ), ) ExportTemplate.objects.bulk_create(export_templates) + + device_object_type = ObjectType.objects.get_for_model(Device) for et in export_templates: - et.object_types.set([ObjectType.objects.get_for_model(Device)]) + et.object_types.set([device_object_type]) class TagTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index c6c53bfcb..ff4543bd2 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -624,8 +624,11 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): export_templates = ( ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'), - ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'), - ExportTemplate(name='Export Template 3', template_code='TESTING'), + ExportTemplate( + name='Export Template 2', template_code='TESTING', description='foobar2', + file_name='export_template_2', file_extension='nagios', + ), + ExportTemplate(name='Export Template 3', template_code='TESTING', file_name='export_filename'), ) ExportTemplate.objects.bulk_create(export_templates) for i, et in enumerate(export_templates): @@ -635,6 +638,9 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'q': 'export_filename'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Export Template 1', 'Export Template 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -649,6 +655,20 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_file_name(self): + params = {'file_name': ['export_filename']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_file_extension(self): + params = {'file_extension': ['nagios']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'file_name': ['export_template_2'], 'file_extension': ['nagios']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'file_name': 'export_filename', 'file_extension': ['nagios']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ImageAttachment.objects.all() diff --git a/netbox/extras/tests/test_utils.py b/netbox/extras/tests/test_utils.py new file mode 100644 index 000000000..b851acab8 --- /dev/null +++ b/netbox/extras/tests/test_utils.py @@ -0,0 +1,19 @@ +from django.test import TestCase + +from extras.models import ExportTemplate +from extras.utils import filename_from_model +from tenancy.models import ContactGroup, TenantGroup +from wireless.models import WirelessLANGroup + + +class FilenameFromModelTests(TestCase): + def test_expected_output(self): + cases = ( + (ExportTemplate, 'netbox_export_templates'), + (ContactGroup, 'netbox_contact_groups'), + (TenantGroup, 'netbox_tenant_groups'), + (WirelessLANGroup, 'netbox_wireless_lan_groups'), + ) + + for model, expected in cases: + self.assertEqual(filename_from_model(model), expected) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index be8d80b2b..0688cd2c2 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -305,7 +305,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): export_templates = ( ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE), ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE), - ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE), + ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3') ) ExportTemplate.objects.bulk_create(export_templates) for et in export_templates: @@ -315,13 +315,14 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Export Template X', 'object_types': [site_type.pk], 'template_code': TEMPLATE_CODE, + 'file_name': 'template_x', } cls.csv_data = ( - "name,object_types,template_code", - f"Export Template 4,dcim.site,{TEMPLATE_CODE}", - f"Export Template 5,dcim.site,{TEMPLATE_CODE}", - f"Export Template 6,dcim.site,{TEMPLATE_CODE}", + "name,object_types,template_code,file_name", + f"Export Template 4,dcim.site,{TEMPLATE_CODE},", + f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5", + f"Export Template 6,dcim.site,{TEMPLATE_CODE},", ) cls.csv_update_data = ( diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index efe7ada5b..411d80f78 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,6 +1,7 @@ import importlib from django.core.exceptions import ImproperlyConfigured +from django.db import models from taggit.managers import _TaggableManager from netbox.context import current_request @@ -15,6 +16,12 @@ __all__ = ( ) +def filename_from_model(model: models.Model) -> str: + """Standardises how we generate filenames from model class for exports""" + base = model._meta.verbose_name_plural.lower().replace(' ', '_') + return f'netbox_{base}' + + def is_taggable(obj): """ Return True if the instance can have Tags assigned to it; False otherwise. diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html index 5a19426f2..f0e370c03 100644 --- a/netbox/templates/extras/exporttemplate.html +++ b/netbox/templates/extras/exporttemplate.html @@ -23,6 +23,10 @@ {% trans "MIME Type" %} {{ object.mime_type|placeholder }}
{% trans "File Name" %}{{ object.file_name|placeholder }}
{% trans "File Extension" %} {{ object.file_extension|placeholder }}