From ae7a47ca60a275ff8574da2f2b50cc055c1091e9 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 10 Mar 2025 11:52:13 -0500 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 ffe035567ad0805b5e180e6ffbbc2701a99536c1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Mar 2025 14:45:44 -0400 Subject: [PATCH 13/21] Closes #18820: Bump minimum PostgreSQL version to 14 (#18909) --- docs/configuration/required-parameters.md | 2 +- docs/installation/1-postgresql.md | 6 +++--- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 2 +- docs/introduction.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index f7e5d71ce..999fc8bb5 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 13 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 8ba302909..536ecea64 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 13 or later required" - NetBox requires PostgreSQL 13 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 14 or later required" + NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -34,7 +34,7 @@ This section entails the installation and configuration of a local PostgreSQL da sudo systemctl enable --now postgresql ``` -Before continuing, verify that you have installed PostgreSQL 13 or later: +Before continuing, verify that you have installed PostgreSQL 14 or later: ```no-highlight psql -V diff --git a/docs/installation/index.md b/docs/installation/index.md index 33888e274..24e966805 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -21,7 +21,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 13+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index e6d05738f..07250e780 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -20,7 +20,7 @@ NetBox requires the following dependencies: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 13+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | ## 3. Install the Latest Release diff --git a/docs/introduction.md b/docs/introduction.md index 75701c119..c8e5ee8ac 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 13+ | +| Database | PostgreSQL 14+ | | Task queuing | Redis/django-rq | From 1b4e00aedaa52ee02e34d364851fd221d2fb5541 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 17 Mar 2025 07:36:34 -0700 Subject: [PATCH 14/21] 18896 Replace STORAGE_BACKEND with STORAGES and support Script running from S3 (#18680) --- base_requirements.txt | 4 + docs/administration/replicating-netbox.md | 2 +- docs/configuration/system.md | 41 ++++++++--- docs/customization/custom-scripts.md | 2 + docs/installation/3-netbox.md | 2 +- netbox/core/models/data.py | 11 --- netbox/core/models/files.py | 36 +++++++-- netbox/extras/forms/scripts.py | 30 ++++++++ netbox/extras/models/mixins.py | 36 ++++++++- netbox/extras/scripts.py | 52 ++++++++++++- netbox/extras/storage.py | 14 ++++ netbox/extras/views.py | 3 +- netbox/netbox/settings.py | 89 ++++++++++++----------- requirements.txt | 1 + 14 files changed, 246 insertions(+), 77 deletions(-) create mode 100644 netbox/extras/storage.py diff --git a/base_requirements.txt b/base_requirements.txt index f76019c27..6921f2d49 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -42,6 +42,10 @@ django-rich # https://github.com/rq/django-rq/blob/master/CHANGELOG.md django-rq +# Provides a variety of storage backends +# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst +django-storages + # Abstraction models for rendering and paginating HTML tables # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md django-tables2 diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 7cc4d3832..f702c3ffd 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema. By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. !!! note - These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend). + These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storages). ### Archive the Media Directory diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 81c1a6a94..aca59e4bb 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -196,23 +196,46 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend` --- -## STORAGE_BACKEND +## STORAGES -Default: None (local storage) +The backend storage engine for handling uploaded files such as [image attachments](../models/extras/imageattachment.md) and [custom scripts](../customization/custom-scripts.md). NetBox integrates with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) libraries, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. -The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. +By default, the following configuration is used: -The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. +```python +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + "scripts": { + "BACKEND": "extras.storage.ScriptFileSystemStorage", + }, +} +``` ---- +Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts. -## STORAGE_CONFIG +If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example: -Default: Empty +```python +STORAGES = { + "scripts": { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + "OPTIONS": { + 'access_key': 'access key', + 'secret_key': 'secret key', + } + }, +} +``` -A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail. +The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html). -If `STORAGE_BACKEND` is not defined, this setting will be ignored. +!!! note + Any keys defined in the `STORAGES` configuration parameter replace those in the default configuration. It is only necessary to define keys within the `STORAGES` for the specific backend(s) you wish to configure. --- diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 1051b31f6..56dd08a76 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -140,6 +140,8 @@ The Script class provides two convenience methods for reading data from files: These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`). +**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage. + ## Logging The Script object provides a set of convenient functions for recording messages at different severity levels: diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 60d60d4f0..d29fa994a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -207,7 +207,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storages) in `configuration.py`. ```no-highlight sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 3f97fb003..9a5da333a 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -357,17 +357,6 @@ class DataFile(models.Model): return is_modified - def write_to_disk(self, path, overwrite=False): - """ - Write the object's data to disk at the specified path - """ - # Check whether file already exists - if os.path.isfile(path) and not overwrite: - raise FileExistsError() - - with open(path, 'wb+') as new_file: - new_file.write(self.data) - class AutoSyncRecord(models.Model): """ diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index cc446bac7..ade13627f 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -1,13 +1,16 @@ import logging import os +from functools import cached_property from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.core.files.storage import storages from django.urls import reverse from django.utils.translation import gettext as _ from ..choices import ManagedFileRootPathChoices +from extras.storage import ScriptFileSystemStorage from netbox.models.features import SyncedDataMixin from utilities.querysets import RestrictedQuerySet @@ -76,15 +79,35 @@ class ManagedFile(SyncedDataMixin, models.Model): return os.path.join(self._resolve_root_path(), self.file_path) def _resolve_root_path(self): - return { - 'scripts': settings.SCRIPTS_ROOT, - 'reports': settings.REPORTS_ROOT, - }[self.file_root] + storage = self.storage + if isinstance(storage, ScriptFileSystemStorage): + return { + 'scripts': settings.SCRIPTS_ROOT, + 'reports': settings.REPORTS_ROOT, + }[self.file_root] + else: + return "" def sync_data(self): if self.data_file: self.file_path = os.path.basename(self.data_path) - self.data_file.write_to_disk(self.full_path, overwrite=True) + self._write_to_disk(self.full_path, overwrite=True) + + def _write_to_disk(self, path, overwrite=False): + """ + Write the object's data to disk at the specified path + """ + # Check whether file already exists + storage = self.storage + if storage.exists(path) and not overwrite: + raise FileExistsError() + + with storage.open(path, 'wb+') as new_file: + new_file.write(self.data) + + @cached_property + def storage(self): + return storages.create_storage(storages.backends["scripts"]) def clean(self): super().clean() @@ -104,8 +127,9 @@ class ManagedFile(SyncedDataMixin, models.Model): def delete(self, *args, **kwargs): # Delete file from disk + storage = self.storage try: - os.remove(self.full_path) + storage.delete(self.full_path) except FileNotFoundError: pass diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 331f7f01f..764246a2d 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -1,11 +1,18 @@ +import os + from django import forms +from django.conf import settings +from django.core.files.storage import storages from django.utils.translation import gettext_lazy as _ +from core.forms import ManagedFileForm from extras.choices import DurationChoices +from extras.storage import ScriptFileSystemStorage from utilities.forms.widgets import DateTimePicker, NumberWithOptions from utilities.datetime import local_now __all__ = ( + 'ScriptFileForm', 'ScriptForm', ) @@ -55,3 +62,26 @@ class ScriptForm(forms.Form): self.cleaned_data['_schedule_at'] = local_now() return self.cleaned_data + + +class ScriptFileForm(ManagedFileForm): + """ + ManagedFileForm with a custom save method to use django-storages. + """ + def save(self, *args, **kwargs): + # If a file was uploaded, save it to disk + if self.cleaned_data['upload_file']: + storage = storages.create_storage(storages.backends["scripts"]) + + filename = self.cleaned_data['upload_file'].name + if isinstance(storage, ScriptFileSystemStorage): + full_path = os.path.join(settings.SCRIPTS_ROOT, filename) + else: + full_path = filename + + self.instance.file_path = full_path + data = self.cleaned_data['upload_file'] + storage.save(filename, data) + + # need to skip ManagedFileForm save method + return super(ManagedFileForm, self).save(*args, **kwargs) diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 0950324c8..f22b32004 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -1,11 +1,31 @@ +import importlib.abc +import importlib.util import os -from importlib.machinery import SourceFileLoader +import sys +from django.core.files.storage import storages __all__ = ( 'PythonModuleMixin', ) +class CustomStoragesLoader(importlib.abc.Loader): + """ + Custom loader for exec_module to use django-storages instead of the file system. + """ + def __init__(self, filename): + self.filename = filename + + def create_module(self, spec): + return None # Use default module creation + + def exec_module(self, module): + storage = storages.create_storage(storages.backends["scripts"]) + with storage.open(self.filename, 'rb') as f: + code = f.read() + exec(code, module.__dict__) + + class PythonModuleMixin: def get_jobs(self, name): @@ -33,6 +53,16 @@ class PythonModuleMixin: return name def get_module(self): - loader = SourceFileLoader(self.python_name, self.full_path) - module = loader.load_module() + """ + Load the module using importlib, but use a custom loader to use django-storages + instead of the file system. + """ + spec = importlib.util.spec_from_file_location(self.python_name, self.name) + if spec is None: + raise ModuleNotFoundError(f"Could not find module: {self.python_name}") + loader = CustomStoragesLoader(self.name) + module = importlib.util.module_from_spec(spec) + sys.modules[self.python_name] = module + loader.exec_module(module) + return module diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index f2bd75a1d..83195402d 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -2,10 +2,12 @@ import inspect import json import logging import os +import re import yaml from django import forms from django.conf import settings +from django.core.files.storage import storages from django.core.validators import RegexValidator from django.utils import timezone from django.utils.functional import classproperty @@ -367,9 +369,46 @@ class BaseScript: def filename(self): return inspect.getfile(self.__class__) + def findsource(self, object): + storage = storages.create_storage(storages.backends["scripts"]) + with storage.open(os.path.basename(self.filename), 'r') as f: + data = f.read() + + # Break the source code into lines + lines = [line + '\n' for line in data.splitlines()] + + # Find the class definition + name = object.__name__ + pat = re.compile(r'^(\s*)class\s*' + name + r'\b') + # use the class definition with the least indentation + candidates = [] + for i in range(len(lines)): + match = pat.match(lines[i]) + if match: + if lines[i][0] == 'c': + return lines, i + + candidates.append((match.group(1), i)) + if not candidates: + raise OSError('could not find class definition') + + # Sort the candidates by whitespace, and by line number + candidates.sort() + return lines, candidates[0][1] + @property def source(self): - return inspect.getsource(self.__class__) + # Can't use inspect.getsource() as it uses os to get the file + # inspect uses ast, but that is overkill for this as we only do + # classes. + object = self.__class__ + + try: + lines, lnum = self.findsource(object) + lines = inspect.getblock(lines[lnum:]) + return ''.join(lines) + except OSError: + return '' @classmethod def _get_vars(cls): @@ -524,7 +563,12 @@ class BaseScript: def load_yaml(self, filename): """ Return data from a YAML file + TODO: DEPRECATED: Remove this method in v4.4 """ + self._log( + _("load_yaml is deprecated and will be removed in v4.4"), + level=LogLevelChoices.LOG_WARNING + ) try: from yaml import CLoader as Loader except ImportError: @@ -539,7 +583,12 @@ class BaseScript: def load_json(self, filename): """ Return data from a JSON file + TODO: DEPRECATED: Remove this method in v4.4 """ + self._log( + _("load_json is deprecated and will be removed in v4.4"), + level=LogLevelChoices.LOG_WARNING + ) file_path = os.path.join(settings.SCRIPTS_ROOT, filename) with open(file_path, 'r') as datafile: data = json.load(datafile) @@ -555,7 +604,6 @@ class BaseScript: Run the report and save its results. Each test method will be executed in order. """ self.logger.info("Running report") - try: for test_name in self.tests: self._current_test = test_name diff --git a/netbox/extras/storage.py b/netbox/extras/storage.py new file mode 100644 index 000000000..ede4fac7f --- /dev/null +++ b/netbox/extras/storage.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.core.files.storage import FileSystemStorage +from django.utils.functional import cached_property + + +class ScriptFileSystemStorage(FileSystemStorage): + """ + Custom storage for scripts - for django-storages as the default one will + go off media-root and raise security errors as the scripts can be outside + the media-root directory. + """ + @cached_property + def base_location(self): + return settings.SCRIPTS_ROOT diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9cb9dd54a..2833cec0d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -12,7 +12,6 @@ from django.utils.translation import gettext as _ from django.views.generic import View from core.choices import ManagedFileRootPathChoices -from core.forms import ManagedFileForm from core.models import Job from core.tables import JobTable from dcim.models import Device, DeviceRole, Platform @@ -1163,7 +1162,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View): @register_model_view(ScriptModule, 'edit') class ScriptModuleCreateView(generic.ObjectEditView): queryset = ScriptModule.objects.all() - form = ManagedFileForm + form = forms.ScriptFileForm def alter_object(self, obj, *args, **kwargs): obj.file_root = ManagedFileRootPathChoices.SCRIPTS diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 57b143f5f..defc5d99b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -17,6 +17,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig from netbox.registry import registry +import storages.utils # type: ignore from utilities.release import load_release_data from utilities.string import trailing_slash @@ -177,7 +178,8 @@ SESSION_COOKIE_PATH = CSRF_COOKIE_PATH SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) -STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) +STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', None) +STORAGES = getattr(configuration, 'STORAGES', {}) TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True) @@ -234,61 +236,64 @@ DATABASES = { # Storage backend # +if STORAGE_BACKEND is not None: + if not STORAGES: + raise ImproperlyConfigured( + "STORAGE_BACKEND and STORAGES are both set, remove the deprecated STORAGE_BACKEND setting." + ) + else: + warnings.warn( + "STORAGE_BACKEND is deprecated, use the new STORAGES setting instead." + ) + +if STORAGE_CONFIG is not None: + warnings.warn( + "STORAGE_CONFIG is deprecated, use the new STORAGES setting instead." + ) + # Default STORAGES for Django -STORAGES = { +DEFAULT_STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, + "scripts": { + "BACKEND": "extras.storage.ScriptFileSystemStorage", + }, } +STORAGES = DEFAULT_STORAGES | STORAGES +# TODO: This code is deprecated and needs to be removed in the future if STORAGE_BACKEND is not None: STORAGES['default']['BACKEND'] = STORAGE_BACKEND - # django-storages - if STORAGE_BACKEND.startswith('storages.'): - try: - import storages.utils # type: ignore - except ModuleNotFoundError as e: - if getattr(e, 'name') == 'storages': - raise ImproperlyConfigured( - f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be " - f"installed by running 'pip install django-storages'." - ) - raise e +# Monkey-patch django-storages to fetch settings from STORAGE_CONFIG +if STORAGE_CONFIG is not None: + def _setting(name, default=None): + if name in STORAGE_CONFIG: + return STORAGE_CONFIG[name] + return globals().get(name, default) + storages.utils.setting = _setting - # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG - def _setting(name, default=None): - if name in STORAGE_CONFIG: - return STORAGE_CONFIG[name] - return globals().get(name, default) - storages.utils.setting = _setting - - # django-storage-swift - elif STORAGE_BACKEND == 'swift.storage.SwiftStorage': - try: - import swift.utils # noqa: F401 - except ModuleNotFoundError as e: - if getattr(e, 'name') == 'swift': - raise ImproperlyConfigured( - f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. " - "It can be installed by running 'pip install django-storage-swift'." - ) - raise e - - # Load all SWIFT_* settings from the user configuration - for param, value in STORAGE_CONFIG.items(): - if param.startswith('SWIFT_'): - globals()[param] = value - -if STORAGE_CONFIG and STORAGE_BACKEND is None: - warnings.warn( - "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be " - "ignored." - ) +# django-storage-swift +if STORAGE_BACKEND == 'swift.storage.SwiftStorage': + try: + import swift.utils # noqa: F401 + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'swift': + raise ImproperlyConfigured( + f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. " + "It can be installed by running 'pip install django-storage-swift'." + ) + raise e + # Load all SWIFT_* settings from the user configuration + for param, value in STORAGE_CONFIG.items(): + if param.startswith('SWIFT_'): + globals()[param] = value +# TODO: End of deprecated code # # Redis diff --git a/requirements.txt b/requirements.txt index 9e0e73d5b..210a0b1d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ django-prometheus==2.3.1 django-redis==5.4.0 django-rich==1.13.0 django-rq==3.0 +django-storages==1.14.4 django-taggit==6.1.0 django-tables2==2.7.5 django-timezone-field==7.1 From f69de12c6d9922ee15138cab326f3cc7ab09dab8 Mon Sep 17 00:00:00 2001 From: bctiemann Date: Mon, 17 Mar 2025 13:02:18 -0400 Subject: [PATCH 15/21] Closes: #15842 - Option to hide local login form if SSO is in use (#18924) Closes: #15842 Branched from #18145 by @tobiasge Provides a new LOGIN_FORM_HIDDEN setting which allows the administrator to hide the local login form, intended only to be used when SSO is used exclusively for authentication. Note that this means local login will be impossible in the event of SSO provider issues, and can be remedied only through a change to the application config and a restart of the service. * #15842 - Hide login form This doesn't implement the full solution proposed in #15842 but enables administrators to hide the login form when users should only login with a SSO provider. To prevent a complete lockout when the SSO provider is having issues the GET parameter `skipsso` can be added to the login URL to show the form regardless. * Remove skipsso backdoor * Add warning --------- Co-authored-by: Tobias Genannt --- docs/configuration/security.md | 11 ++++ netbox/account/views.py | 2 + netbox/netbox/configuration_example.py | 3 ++ netbox/netbox/settings.py | 1 + netbox/templates/login.html | 73 ++++++++++++++------------ 5 files changed, 57 insertions(+), 33 deletions(-) diff --git a/docs/configuration/security.md b/docs/configuration/security.md index b97f31432..172034b4f 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -186,6 +186,17 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u --- +## LOGIN_FORM_HIDDEN + +Default: False + +Option to hide the login form when only SSO authentication is in use. + +!!! warning + If the SSO provider is unreachable, login to NetBox will be impossible if this option is enabled. The only recourse is to disable it in the local configuration and restart the NetBox service. + +--- + ## LOGOUT_REDIRECT_URL Default: `'home'` diff --git a/netbox/account/views.py b/netbox/account/views.py index 05f40df3f..3a2dc6b32 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -89,10 +89,12 @@ class LoginView(View): if request.user.is_authenticated: logger = logging.getLogger('netbox.auth.login') return self.redirect_to_next(request, logger) + login_form_hidden = settings.LOGIN_FORM_HIDDEN return render(request, self.template_name, { 'form': form, 'auth_backends': self.get_auth_backends(request), + 'login_form_hidden': login_form_hidden, }) def post(self, request): diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 84ead5339..a07661208 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -164,6 +164,9 @@ LOGIN_REQUIRED = True # re-authenticate. (Default: 1209600 [14 days]) LOGIN_TIMEOUT = None +# Hide the login form. Useful when only allowing SSO authentication. +LOGIN_FORM_HIDDEN = False + # The view name or URL to which users are redirected after logging out. LOGOUT_REDIRECT_URL = 'home' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index defc5d99b..0c8f7ca70 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -129,6 +129,7 @@ LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) +LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False) LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home') MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) diff --git a/netbox/templates/login.html b/netbox/templates/login.html index e50303911..079d66a67 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -34,48 +34,55 @@ {% endif %}
-
-

{% trans "Log In" %}

+ {% if not login_form_hidden %} +
+

{% trans "Log In" %}

- {# Login form #} -
- {% csrf_token %} + {# Login form #} + + {% csrf_token %} - {# Set post-login URL #} - {% if 'next' in request.GET %} - - {% elif 'next' in request.POST %} - - {% endif %} + {# Set post-login URL #} + {% if 'next' in request.GET %} + + {% elif 'next' in request.POST %} + + {% endif %} -
- - {{ form.username }} - {% for error in form.username.errors %} -
{{ error }}
- {% endfor %} -
+
+ + {{ form.username }} + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
-
- - {{ form.password }} - {% for error in form.password.errors %} -
{{ error }}
- {% endfor %} -
+
+ + {{ form.password }} + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
- -
-
+ + +
+ {% endif %} {# SSO login #} {% if auth_backends %} -
{% trans "Or" context "Denotes an alternative option" %}
+ {% if not login_form_hidden %} +
{% trans "Or" context "Denotes an alternative option" %}
+ {% endif %}
+ {% if login_form_hidden %} +

{% trans "Log In" %}

+ {% endif %}
{% for backend in auth_backends %}
From d4f8cb72aa6ef010b3746c29973ecf276f392bf9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Mar 2025 13:23:37 -0400 Subject: [PATCH 16/21] Closes #18780: External database configuration (#18912) --- docs/configuration/required-parameters.md | 45 ++++++++++++++++++----- docs/configuration/system.md | 8 ++++ docs/development/getting-started.md | 2 +- docs/installation/3-netbox.md | 24 +++++++----- netbox/netbox/configuration_example.py | 18 +++++---- netbox/netbox/configuration_testing.py | 16 ++++---- netbox/netbox/settings.py | 30 ++++++++------- 7 files changed, 94 insertions(+), 49 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 999fc8bb5..4a18e8a6c 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,30 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +!!! warning "Legacy Configuration Parameter" + The `DATABASE` configuration parameter is deprecated and will be removed in a future release. Users are advised to adopt the new `DATABASES` (plural) parameter, which allows for the configuration of multiple databases. + +See the [`DATABASES`](#databases) configuration below for usage. + +--- + +## DATABASES + +!!! info "This parameter was introduced in NetBox v4.3." + +NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries: + +```python +DATABASES = { + 'default': {...}, + 'external1': {...}, + 'external2': {...}, +} +``` + +NetBox itself requires only that a `default` database is defined. However, certain plugins may require the configuration of additional databases. (Consider also configuring the [`DATABASE_ROUTERS`](./system.md#database_routers) parameter when multiple databases are in use.) + +The following parameters must be defined for each database: * `NAME` - Database name * `USER` - PostgreSQL username @@ -38,14 +61,16 @@ NetBox requires access to a PostgreSQL 14 or later database service to store dat Example: ```python -DATABASE = { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age + } } ``` @@ -53,7 +78,7 @@ DATABASE = { NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). !!! warning - Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql. + The `ENGINE` parameter must specify a PostgreSQL-compatible database backend. If not defined, the default engine `django.db.backends.postgresql` will be used. --- diff --git a/docs/configuration/system.md b/docs/configuration/system.md index aca59e4bb..11db09370 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -12,6 +12,14 @@ BASE_PATH = 'netbox/' --- +## DATABASE_ROUTERS + +Default: `[]` (empty list) + +An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured. + +--- + ## DEFAULT_LANGUAGE Default: `en-us` (US English) diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 0b77bfd4d..129bf2d4b 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -115,7 +115,7 @@ You may also need to set up the yarn packages as shown in the [Web UI Developmen Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: * `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes -* `DATABASE`: PostgreSQL database connection parameters +* `DATABASES`: PostgreSQL database connection parameters * `REDIS`: Redis configuration (if different from the defaults) * `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key) * `DEBUG`: Set to `True` diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index d29fa994a..33eef6057 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -128,7 +128,7 @@ sudo cp configuration_example.py configuration.py Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: * `ALLOWED_HOSTS` -* `DATABASE` +* `DATABASES` (or `DATABASE`) * `REDIS` * `SECRET_KEY` @@ -146,18 +146,22 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins ALLOWED_HOSTS = ['*'] ``` -### DATABASE +### DATABASES -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters. +This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins. + +A username and password must be defined for the default database. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#databases) for more detail on individual parameters. ```python -DATABASE = { - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age (seconds) +DATABASES = { + 'default': { + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age (seconds) + } } ``` diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index a07661208..27d44a2ff 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -12,14 +12,16 @@ ALLOWED_HOSTS = [] # PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: # https://docs.djangoproject.com/en/stable/ref/settings/#databases -DATABASE = { - 'ENGINE': 'django.db.backends.postgresql', # Database engine - 'NAME': 'netbox', # Database name - 'USER': '', # PostgreSQL username - 'PASSWORD': '', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', # Database engine + 'NAME': 'netbox', # Database name + 'USER': '', # PostgreSQL username + 'PASSWORD': '', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age + } } # Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index cec05cabb..142b50bb0 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -5,13 +5,15 @@ ALLOWED_HOSTS = ['*'] -DATABASE = { - 'NAME': 'netbox', - 'USER': 'netbox', - 'PASSWORD': 'netbox', - 'HOST': 'localhost', - 'PORT': '', - 'CONN_MAX_AGE': 300, +DATABASES = { + 'default': { + 'NAME': 'netbox', + 'USER': 'netbox', + 'PASSWORD': 'netbox', + 'HOST': 'localhost', + 'PORT': '', + 'CONN_MAX_AGE': 300, + } } PLUGINS = [ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0c8f7ca70..3b47ab541 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -53,10 +53,14 @@ except ModuleNotFoundError as e: ) raise -# Check for missing required configuration parameters -for parameter in ('ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS'): +# Check for missing/conflicting required configuration parameters +for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'): if not hasattr(configuration, parameter): raise ImproperlyConfigured(f"Required parameter {parameter} is missing from configuration.") +if not hasattr(configuration, 'DATABASE') and not hasattr(configuration, 'DATABASES'): + raise ImproperlyConfigured("The database configuration must be defined using DATABASE or DATABASES.") +elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'): + raise ImproperlyConfigured("DATABASE and DATABASES may not be set together. The use of DATABASES is encouraged.") # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) @@ -84,7 +88,9 @@ CSRF_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}' CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440) -DATABASE = getattr(configuration, 'DATABASE') # Required +DATABASE = getattr(configuration, 'DATABASE', None) # Legacy DB definition +DATABASE_ROUTERS = getattr(configuration, 'DATABASE_ROUTERS', []) +DATABASES = getattr(configuration, 'DATABASES', {'default': DATABASE}) DEBUG = getattr(configuration, 'DEBUG', False) DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None) DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { @@ -220,17 +226,15 @@ for path in PROXY_ROUTERS: # Database # -# Set the database engine -if 'ENGINE' not in DATABASE: - if METRICS_ENABLED: - DATABASE.update({'ENGINE': 'django_prometheus.db.backends.postgresql'}) - else: - DATABASE.update({'ENGINE': 'django.db.backends.postgresql'}) +# Verify that a default database has been configured +if 'default' not in DATABASES: + raise ImproperlyConfigured("No default database has been configured.") -# Define the DATABASES setting for Django -DATABASES = { - 'default': DATABASE, -} +# Set the database engine +if 'ENGINE' not in DATABASES['default']: + DATABASES['default'].update({ + 'ENGINE': 'django_prometheus.db.backends.postgresql' if METRICS_ENABLED else 'django.db.backends.postgresql' + }) # From af5ec19430f3103b79ce414ee203398d46f4c643 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 18 Mar 2025 11:05:02 -0700 Subject: [PATCH 17/21] 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 18/21] 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 19/21] 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 20/21] 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 21/21] 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 }}